use super::*;
fn word_part_tree_contains_variable(parts: &[WordPartNode], expected: &str) -> bool {
parts.iter().any(|part| match &part.kind {
WordPart::Variable(name) => name == expected,
WordPart::DoubleQuoted { parts, .. } => word_part_tree_contains_variable(parts, expected),
_ => false,
})
}
fn collect_bourne_parameter_names(parts: &[WordPartNode], names: &mut Vec<String>) {
for part in parts {
match &part.kind {
WordPart::DoubleQuoted { parts, .. } => collect_bourne_parameter_names(parts, names),
WordPart::Parameter(parameter) => {
let name = match ¶meter.syntax {
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access {
reference,
})
| ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Length {
reference,
})
| ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Indices {
reference,
})
| ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Indirect {
reference,
..
})
| ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
reference,
..
})
| ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Slice {
reference,
..
})
| ParameterExpansionSyntax::Bourne(
BourneParameterExpansion::Transformation { reference, .. },
) => Some(reference.name.to_string()),
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::PrefixMatch {
prefix,
..
}) => Some(prefix.to_string()),
ParameterExpansionSyntax::Zsh(_) => None,
};
if let Some(name) = name {
names.push(name);
}
}
_ => {}
}
}
}
#[test]
fn test_current_word_cache_tracks_token_changes() {
let input = "\"$foo\" bar\n";
let mut parser = Parser::new(input);
let first = parser.current_word().unwrap();
assert_eq!(first.render(input), "$foo");
assert!(is_fully_quoted(&first));
let [quoted_part] = parser.current_word_cache.as_ref().unwrap().parts.as_slice() else {
panic!("expected one quoted part");
};
let WordPart::DoubleQuoted { parts, .. } = "ed_part.kind else {
panic!("expected double-quoted word");
};
assert!(matches!(
parts.as_slice(),
[part] if matches!(&part.kind, WordPart::Variable(_))
));
let repeated = parser.current_word().unwrap();
assert_eq!(repeated.span, first.span);
parser.advance();
assert!(parser.current_word_cache.is_none());
let next = parser.current_word().unwrap();
assert_eq!(next.render(input), "bar");
assert!(parser.current_word_cache.is_none());
}
#[test]
fn test_parse_word_fragment_preserves_original_span_for_cooked_text() {
let source = r#"foo\/bar"#;
let span = Span::from_positions(Position::new(), Position::new().advanced_by(source));
let word = Parser::parse_word_fragment(source, "foo/bar", span);
assert_eq!(word.render(source), "foo/bar");
assert_eq!(word.span, span);
assert_eq!(word.span.slice(source), source);
assert!(matches!(
&word.parts[..],
[WordPartNode {
kind: WordPart::Literal(text),
..
}] if !text.is_source_backed() && text == "foo/bar"
));
}
#[test]
fn test_parse_quoted_flow_control_name_stays_simple_command() {
let input = "'break' 2";
let parser = Parser::new(input);
let script = parser.parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert!(is_fully_quoted(&command.name));
assert_eq!(command.name.render(input), "break");
assert_eq!(command.args[0].render(input), "2");
}
#[test]
fn test_parse_mixed_literal_word_consumes_segmented_token_directly() {
let input = "printf foo\"bar\"'baz'";
let parser = Parser::new(input);
let script = parser.parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let arg = &command.args[0];
assert!(!is_fully_quoted(arg));
assert_eq!(arg.render(input), "foobarbaz");
assert_eq!(arg.parts.len(), 3);
assert_eq!(arg.part_span(0).unwrap().slice(input), "foo");
assert_eq!(arg.part_span(1).unwrap().slice(input), "\"bar\"");
assert_eq!(arg.part_span(2).unwrap().slice(input), "'baz'");
let WordPart::DoubleQuoted { parts, .. } = &arg.parts[1].kind else {
panic!("expected double-quoted middle part");
};
assert_eq!(parts[0].span.slice(input), "bar");
let WordPart::SingleQuoted { value, .. } = &arg.parts[2].kind else {
panic!("expected single-quoted suffix part");
};
assert_eq!(value.slice(input), "baz");
}
#[test]
fn test_parse_single_quoted_prefix_word_consumes_segmented_token_directly() {
let input = "printf 'foo'bar";
let parser = Parser::new(input);
let script = parser.parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let arg = &command.args[0];
assert!(!is_fully_quoted(arg));
assert_eq!(arg.render(input), "foobar");
assert_eq!(arg.parts.len(), 2);
assert_eq!(arg.part_span(0).unwrap().slice(input), "'foo'");
assert_eq!(arg.part_span(1).unwrap().slice(input), "bar");
}
#[test]
fn test_parse_variable() {
let parser = Parser::new("echo $HOME");
let script = parser.parse().unwrap().file;
if let AstCommand::Simple(cmd) = &script.body[0].command {
assert_eq!(cmd.args.len(), 1);
assert_eq!(cmd.args[0].parts.len(), 1);
assert!(matches!(&cmd.args[0].parts[0].kind, WordPart::Variable(v) if v == "HOME"));
} else {
panic!("expected simple command");
}
}
#[test]
fn test_parse_word_string_keeps_escaped_dollar_literal() {
let input = r#"\$HOME"#;
let word = Parser::parse_word_string(input);
assert_eq!(word.render(input), "$HOME");
assert_eq!(word.render_syntax(input), input);
assert!(matches!(
word.parts.as_slice(),
[WordPartNode {
kind: WordPart::Literal(text),
..
}] if text.is_source_backed() && text.as_str(input, word.parts[0].span) == "$HOME"
));
}
#[test]
fn test_parse_escaped_dollar_expansions_stay_literal_in_script_words() {
let input = r#"echo \$HOME \${USER}"#;
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.args.len(), 2);
let expected = [("$HOME", r#"\$HOME"#), ("${USER}", r#"\${USER}"#)];
for (word, (decoded, syntax)) in command.args.iter().zip(expected) {
assert_eq!(word.render(input), decoded);
assert_eq!(word.render_syntax(input), syntax);
assert!(matches!(
word.parts.as_slice(),
[WordPartNode {
kind: WordPart::Literal(text),
..
}] if text.is_source_backed() && text.as_str(input, word.parts[0].span) == decoded
));
}
}
#[test]
fn test_parse_escaped_braced_parameter_with_nested_default_stays_literal() {
let input = r#"echo \${x:-$HOME}"#;
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert_eq!(word.render(input), "${x:-$HOME}");
assert_eq!(word.render_syntax(input), r#"\${x:-$HOME}"#);
assert!(matches!(
word.parts.as_slice(),
[WordPartNode {
kind: WordPart::Literal(text),
..
}] if text.is_source_backed() && text.as_str(input, word.parts[0].span) == "${x:-$HOME}"
));
}
#[test]
fn test_parse_escaped_backslash_then_variable_keeps_variable_live() {
let input = "echo \\\\$HOME\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert!(matches!(
word.parts.as_slice(),
[
WordPartNode {
kind: WordPart::Literal(text),
..
},
WordPartNode {
kind: WordPart::Variable(name),
..
}
] if text.as_str(input, word.parts[0].span) == "\\"
&& name.as_str() == "HOME"
));
}
#[test]
fn test_parse_mixed_quoted_and_cooked_plain_continuation_keeps_variable_live() {
let input = "echo \"x\"\\\\$HOME\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert!(matches!(
word.parts.as_slice(),
[
WordPartNode {
kind: WordPart::DoubleQuoted { parts, .. },
..
},
WordPartNode {
kind: WordPart::Literal(text),
..
},
WordPartNode {
kind: WordPart::Variable(name),
..
}
] if matches!(
parts.as_slice(),
[WordPartNode {
kind: WordPart::Literal(inner),
..
}] if inner.as_str(input, parts[0].span) == "x"
) && text.as_str(input, word.parts[1].span) == "\\"
&& name.as_str() == "HOME"
));
}
#[test]
fn test_parse_escaped_quote_before_command_substitution_keeps_substitution_live() {
let input = "echo TERMUX_SUBPKG_INCLUDE=\\\"$(find ${_ADD_PREFIX}lib{,32})\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert!(
word.parts
.iter()
.any(|part| !matches!(part.kind, WordPart::Literal(_))),
"parts: {:?}",
word.parts
);
assert_eq!(word.brace_syntax().len(), 0);
}
#[test]
fn test_parse_escaped_command_substitution_stays_literal_in_double_quotes() {
let input = r#"echo "\$(pwd)""#;
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert_eq!(word.render(input), "$(pwd)");
assert_eq!(word.render_syntax(input), r#""\$(pwd)""#);
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
assert!(matches!(
parts.as_slice(),
[WordPartNode {
kind: WordPart::Literal(text),
..
}] if text.is_source_backed() && text.as_str(input, parts[0].span) == "$(pwd)"
));
}
#[test]
fn test_parse_escaped_backslash_before_command_substitution_keeps_substitution_live() {
let input = "echo \"\\\\$(pwd)\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
let slices: Vec<&str> = parts.iter().map(|part| part.span.slice(input)).collect();
assert_eq!(slices, vec![r#"\\"#, "$(pwd)"]);
let WordPart::Literal(text) = &parts[0].kind else {
panic!("expected literal backslash prefix");
};
assert_eq!(text.as_str(input, parts[0].span), r#"\"#);
let WordPart::CommandSubstitution { body, .. } = &parts[1].kind else {
panic!("expected live command substitution");
};
let inner = expect_simple(&body[0]);
assert_eq!(inner.name.render(input), "pwd");
}
#[test]
fn test_parse_escaped_literal_before_command_substitution_keeps_following_substitution_live() {
let input = "echo $VERSION\\_$(echo hi)\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert!(matches!(
word.parts.as_slice(),
[
WordPartNode {
kind: WordPart::Variable(name),
..
},
WordPartNode {
kind: WordPart::Literal(text),
..
},
WordPartNode {
kind: WordPart::CommandSubstitution { body, .. },
span,
}
] if name.as_str() == "VERSION"
&& text.as_str(input, word.parts[1].span) == "_"
&& span.slice(input) == "$(echo hi)"
&& matches!(&body[0].command, AstCommand::Simple(inner) if inner.name.render(input) == "echo")
));
}
#[test]
fn test_parse_escaped_quotes_before_command_substitution_keep_nested_pipeline_live() {
let input = "#!/bin/sh\necho -n \"\\\"adp_$(echo $var | tr A-Z a-z)\\\": [\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[1];
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
let WordPart::CommandSubstitution { body, .. } = &parts[1].kind else {
panic!("expected command substitution");
};
let AstCommand::Binary(binary) = &body[0].command else {
panic!("expected piped command");
};
let first = expect_simple(&binary.left);
assert_eq!(first.name.render(input), "echo");
assert_eq!(first.args[0].render(input), "$var");
let second = expect_simple(&binary.right);
assert_eq!(second.name.render(input), "tr");
assert_eq!(second.args[0].render(input), "A-Z");
assert_eq!(second.args[1].render(input), "a-z");
}
#[test]
fn test_parse_command_substitution_with_piped_tr_after_quoted_variable_keeps_nested_pipeline_live()
{
let input = "ATLAS_SHARED=$(echo \"$ATLAS_SHARED\"|cut -b 1|tr a-z A-Z)\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
let WordPart::CommandSubstitution { body, .. } = &word.parts[0].kind else {
panic!("expected command substitution");
};
let AstCommand::Binary(pipeline) = &body[0].command else {
panic!("expected piped command");
};
let AstCommand::Binary(prefix) = &pipeline.left.command else {
panic!("expected nested pipeline");
};
let first = expect_simple(&prefix.left);
assert_eq!(first.name.render(input), "echo");
assert_eq!(first.args[0].render(input), "$ATLAS_SHARED");
let middle = expect_simple(&prefix.right);
assert_eq!(middle.name.render(input), "cut");
assert_eq!(middle.args[0].render(input), "-b");
assert_eq!(middle.args[1].render(input), "1");
let tr_command = expect_simple(&pipeline.right);
assert_eq!(tr_command.name.render(input), "tr");
assert_eq!(tr_command.args[0].render(input), "a-z");
assert_eq!(tr_command.args[1].render(input), "A-Z");
}
#[test]
fn test_parse_positional_parameters() {
let parser = Parser::new("echo $@ $*");
let script = parser.parse().unwrap().file;
if let AstCommand::Simple(cmd) = &script.body[0].command {
assert_eq!(cmd.args.len(), 2);
assert!(matches!(&cmd.args[0].parts[0].kind, WordPart::Variable(v) if v == "@"));
assert!(matches!(&cmd.args[1].parts[0].kind, WordPart::Variable(v) if v == "*"));
} else {
panic!("expected simple command");
}
}
#[test]
fn test_function_keyword_without_parens_preserves_surface_form() {
let input = "function inc { :; }\n";
let script = Parser::new(input).parse().unwrap().file;
let function = expect_function(&script.body[0]);
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::BraceGroup(body) = compound else {
panic!("expected brace-group function body");
};
assert!(function.uses_function_keyword());
assert!(!function.has_name_parens());
assert_eq!(
function
.header
.function_keyword_span
.map(|span| span.slice(input)),
Some("function")
);
assert_eq!(function.header.trailing_parens_span, None);
assert!(redirects.is_empty());
assert_eq!(body.len(), 1);
}
#[test]
fn test_posix_function_keyword_without_parens_preserves_surface_form() {
let input = "function inc { :; }\n";
let script = Parser::with_dialect(input, ShellDialect::Posix)
.parse()
.unwrap()
.file;
let function = expect_function(&script.body[0]);
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::BraceGroup(body) = compound else {
panic!("expected brace-group function body");
};
assert!(function.uses_function_keyword());
assert!(!function.has_name_parens());
assert_eq!(
function
.header
.function_keyword_span
.map(|span| span.slice(input)),
Some("function")
);
assert_eq!(function.header.trailing_parens_span, None);
assert!(redirects.is_empty());
assert_eq!(body.len(), 1);
}
#[test]
fn test_function_keyword_with_parens_preserves_surface_form() {
let input = "function inc() { :; }\n";
let script = Parser::new(input).parse().unwrap().file;
let function = expect_function(&script.body[0]);
let (compound, redirects) = expect_compound(function.body.as_ref());
assert!(function.uses_function_keyword());
assert!(function.has_name_parens());
assert_eq!(
function
.header
.function_keyword_span
.map(|span| span.slice(input)),
Some("function")
);
assert_eq!(
function
.header
.trailing_parens_span
.map(|span| span.slice(input)),
Some("()")
);
assert!(matches!(compound, AstCompoundCommand::BraceGroup(_)));
assert!(redirects.is_empty());
}
#[test]
fn test_function_keyword_allows_subshell_body() {
let input = "function inc_subshell() ( j=$((j+5)); )\n";
let script = Parser::new(input).parse().unwrap().file;
let function = expect_function(&script.body[0]);
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::Subshell(body) = compound else {
panic!("expected subshell function body");
};
assert!(function.uses_function_keyword());
assert!(function.has_name_parens());
assert!(redirects.is_empty());
assert_eq!(body.len(), 1);
}
#[test]
fn test_function_keyword_allows_newline_conditional_body() {
let input = "function f()\n[[ -n x ]]\n";
let script = Parser::new(input).parse().unwrap().file;
let function = expect_function(&script.body[0]);
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::Conditional(command) = compound else {
panic!("expected conditional function body");
};
assert!(function.uses_function_keyword());
assert!(function.has_name_parens());
assert!(redirects.is_empty());
assert_eq!(command.span.slice(input), "[[ -n x ]]");
}
#[test]
fn test_function_keyword_rejects_same_line_conditional_body() {
let parser = Parser::new("function f() [[ -n x ]]\n");
assert!(
parser.parse().is_err(),
"same-line conditional body should be rejected for function keyword definitions"
);
}
#[test]
fn test_function_keyword_accepts_bash_reserved_name_tokens() {
let input = "\
function [[ { :; }
function ]] { :; }
function { { :; }
function } { :; }
";
let script = Parser::new(input).parse().unwrap().file;
let names = script
.body
.iter()
.map(expect_function)
.map(|function| function.header.entries[0].word.span.slice(input))
.collect::<Vec<_>>();
assert_eq!(names, vec!["[[", "]]", "{", "}"]);
}
#[test]
fn test_zsh_function_keyword_allows_empty_compact_brace_body() {
let input = "function quit() {}\n";
let script = Parser::with_dialect(input, ShellDialect::Zsh)
.parse()
.unwrap()
.file;
let function = expect_function(&script.body[0]);
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::BraceGroup(body) = compound else {
panic!("expected brace-group function body");
};
assert!(function.uses_function_keyword());
assert!(function.has_name_parens());
assert!(redirects.is_empty());
assert!(body.is_empty());
}
#[test]
fn test_non_zsh_dialects_reject_compact_function_keyword_brace_body() {
for dialect in [ShellDialect::Posix, ShellDialect::Mksh, ShellDialect::Bash] {
assert!(
Parser::with_dialect("function quit() {}\n", dialect)
.parse()
.is_err(),
"expected {dialect:?} to reject compact function-keyword brace body",
);
}
}
#[test]
fn test_adjacent_left_paren_after_command_word_is_a_parse_error() {
let parser = Parser::new("foo$identity('z')\n");
assert!(
parser.parse().is_err(),
"a command word followed immediately by '(' should be rejected"
);
}
#[test]
fn test_unterminated_single_quote_rejected() {
let parser = Parser::new("echo 'unterminated");
assert!(
parser.parse().is_err(),
"unterminated single quote should be rejected"
);
}
#[test]
fn test_unterminated_double_quote_rejected() {
let parser = Parser::new("echo \"unterminated");
assert!(
parser.parse().is_err(),
"unterminated double quote should be rejected"
);
}
#[test]
fn test_nested_expansion_in_array_subscript() {
// ${arr[$RANDOM % ${#arr[@]}]} must parse without error.
// The subscript contains ${#arr[@]} which has its own [ and ].
let input = "echo ${arr[$RANDOM % ${#arr[@]}]}";
let parser = Parser::new(input);
let script = parser.parse().unwrap().file;
assert_eq!(script.body.len(), 1);
if let AstCommand::Simple(cmd) = &script.body[0].command {
assert_eq!(cmd.name.render(input), "echo");
assert_eq!(cmd.args.len(), 1);
// The arg should contain an ArrayAccess with the full nested index
let arg = &cmd.args[0];
let has_array_access = arg.parts.iter().any(|p| {
array_access_reference(&p.kind).is_some_and(|reference| {
reference.name == "arr"
&& reference
.subscript
.as_ref()
.is_some_and(|subscript| subscript.text.slice(input).contains("${#arr[@]}"))
})
});
assert!(
has_array_access,
"expected ArrayAccess with nested index, got: {:?}",
arg.parts
);
} else {
panic!("expected simple command");
}
}
/// Assignment with nested subscript must parse (previously caused fuel exhaustion).
#[test]
fn test_assignment_nested_subscript_parses() {
let parser = Parser::new("x=${arr[$RANDOM % ${#arr[@]}]}");
assert!(
parser.parse().is_ok(),
"assignment with nested subscript should parse"
);
}
#[test]
fn test_indexed_assignment_with_spaces_in_subscript_parses() {
let input = "a[1 + 2]=3\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.assignments.len(), 1);
assert_eq!(command.assignments[0].target.name, "a");
expect_subscript(&command.assignments[0].target, input, "1 + 2");
assert!(command.name.render(input).is_empty());
}
#[test]
fn test_parenthesized_indexed_assignment_is_not_function_definition() {
let input = "a[(1+2)*3]=9\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.assignments.len(), 1);
assert_eq!(command.assignments[0].target.name, "a");
expect_subscript(&command.assignments[0].target, input, "(1+2)*3");
assert!(command.name.render(input).is_empty());
}
#[test]
fn test_assignment_index_ast_tracks_arithmetic_subscripts() {
let input = "a[i + 1]=x\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let assignment = &command.assignments[0];
let subscript_ast = assignment
.target
.subscript
.as_ref()
.and_then(|subscript| subscript.arithmetic_ast.as_ref());
let expr = subscript_ast.expect("expected arithmetic subscript AST");
let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
panic!("expected additive subscript");
};
assert_eq!(*op, ArithmeticBinaryOp::Add);
expect_variable(left.as_ref(), "i");
expect_number(right.as_ref(), input, "1");
}
#[test]
fn test_decl_name_and_array_access_attach_arithmetic_index_asts() {
let input = "declare foo[1+2]\necho ${arr[i+1]}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Decl(command) = &script.body[0].command else {
panic!("expected declaration command");
};
let DeclOperand::Name(name) = &command.operands[0] else {
panic!("expected declaration name operand");
};
let subscript_ast = name
.subscript
.as_ref()
.and_then(|subscript| subscript.arithmetic_ast.as_ref());
let expr = subscript_ast.expect("expected declaration index AST");
let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
panic!("expected additive expression in declaration index");
};
assert_eq!(*op, ArithmeticBinaryOp::Add);
expect_number(left.as_ref(), input, "1");
expect_number(right.as_ref(), input, "2");
let AstCommand::Simple(command) = &script.body[1].command else {
panic!("expected simple command");
};
let reference = expect_array_access(&command.args[0]);
expect_subscript(reference, input, "i+1");
let expr = reference
.subscript
.as_ref()
.and_then(|subscript| subscript.arithmetic_ast.as_ref())
.expect("expected array access index AST");
let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
panic!("expected additive array index");
};
assert_eq!(*op, ArithmeticBinaryOp::Add);
expect_variable(left.as_ref(), "i");
expect_number(right.as_ref(), input, "1");
}
#[test]
fn test_substring_and_array_slice_attach_arithmetic_companion_asts() {
let input = "echo ${s:i+1:len*2} ${arr[@]:i:j}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let (_, offset_ast, length_ast) = expect_substring_part(&command.args[0].parts[0].kind);
let offset_ast = offset_ast.as_ref().expect("expected substring offset AST");
let ArithmeticExpr::Binary { left, op, right } = &offset_ast.kind else {
panic!("expected additive substring offset");
};
assert_eq!(*op, ArithmeticBinaryOp::Add);
expect_variable(left, "i");
expect_number(right, input, "1");
let length_ast = length_ast.as_ref().expect("expected substring length AST");
let ArithmeticExpr::Binary {
left: len_left,
op: len_op,
right: len_right,
} = &length_ast.kind
else {
panic!("expected multiplicative substring length");
};
assert_eq!(*len_op, ArithmeticBinaryOp::Multiply);
expect_variable(len_left, "len");
expect_number(len_right, input, "2");
let (_, offset_ast, length_ast) = expect_array_slice_part(&command.args[1].parts[0].kind);
expect_variable(
offset_ast
.as_ref()
.expect("expected array slice offset AST"),
"i",
);
expect_variable(
length_ast
.as_ref()
.expect("expected array slice length AST"),
"j",
);
}
#[test]
fn test_substring_offset_preserves_postfix_and_nested_length_arithmetic() {
let input = "echo \"${chars:spin_i++%${#chars}:1}\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let WordPart::DoubleQuoted { parts, .. } = &command.args[0].parts[0].kind else {
panic!("expected double-quoted word");
};
let (_, offset_ast, length_ast) = expect_substring_part(&parts[0].kind);
let ArithmeticExpr::Binary { left, op, right } = &offset_ast
.as_ref()
.expect("expected offset arithmetic AST")
.kind
else {
panic!("expected modulo offset expression");
};
assert_eq!(*op, ArithmeticBinaryOp::Modulo);
let ArithmeticExpr::Postfix { expr, op } = &left.kind else {
panic!("expected postfix increment on the left side");
};
assert_eq!(*op, ArithmeticPostfixOp::Increment);
expect_variable(expr, "spin_i");
expect_shell_word(right, input, "${#chars}");
expect_number(
length_ast.as_ref().expect("expected substring length AST"),
input,
"1",
);
}
#[test]
fn test_non_arithmetic_subscripts_leave_companion_ast_empty() {
let input = "echo ${arr[@]} ${arr[*]} ${map[\"key\"]}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let reference = expect_array_access(&command.args[0]);
let subscript = reference
.subscript
.as_ref()
.expect("expected first array subscript");
assert_eq!(subscript.selector(), Some(SubscriptSelector::At));
assert!(subscript.arithmetic_ast.is_none());
let reference = expect_array_access(&command.args[1]);
let subscript = reference
.subscript
.as_ref()
.expect("expected second array subscript");
assert_eq!(subscript.selector(), Some(SubscriptSelector::Star));
assert!(subscript.arithmetic_ast.is_none());
let reference = expect_array_access(&command.args[2]);
let subscript = reference
.subscript
.as_ref()
.expect("expected third array subscript");
assert_eq!(subscript.selector(), None);
assert!(subscript.arithmetic_ast.is_none());
}
#[test]
fn test_parameter_forms_preserve_selector_kinds() {
let input = "echo ${arr[@]} ${arr[*]} ${#arr[@]} ${!arr[*]} ${arr[@]:1:2}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let reference = expect_array_access(&command.args[0]);
assert_eq!(
reference.subscript.as_ref().and_then(Subscript::selector),
Some(SubscriptSelector::At)
);
let reference = expect_array_access(&command.args[1]);
assert_eq!(
reference.subscript.as_ref().and_then(Subscript::selector),
Some(SubscriptSelector::Star)
);
let reference = expect_array_length_part(&command.args[2].parts[0].kind);
assert_eq!(
reference.subscript.as_ref().and_then(Subscript::selector),
Some(SubscriptSelector::At)
);
let reference = expect_array_indices_part(&command.args[3].parts[0].kind);
assert_eq!(
reference.subscript.as_ref().and_then(Subscript::selector),
Some(SubscriptSelector::Star)
);
let (reference, _, _) = expect_array_slice_part(&command.args[4].parts[0].kind);
assert_eq!(
reference.subscript.as_ref().and_then(Subscript::selector),
Some(SubscriptSelector::At)
);
}
#[test]
fn test_braced_special_parameters_parse_as_parameter_accesses() {
let input = "echo ${#} ${$} ${!}\n";
let script = Parser::new(input).parse().unwrap().file;
let command = expect_simple(&script.body[0]);
let hash = expect_array_access(&command.args[0]);
assert_eq!(hash.name.as_str(), "#");
assert_eq!(hash.name_span.slice(input), "#");
let pid = expect_array_access(&command.args[1]);
assert_eq!(pid.name.as_str(), "$");
assert_eq!(pid.name_span.slice(input), "$");
let bang = expect_array_access(&command.args[2]);
assert_eq!(bang.name.as_str(), "!");
assert_eq!(bang.name_span.slice(input), "!");
}
#[test]
fn test_indirect_expansions_preserve_reference_structure() {
let input = "echo ${!tools[$target]} ${!var//$'\\n'/' '}\n";
let script = Parser::new(input).parse().unwrap().file;
let command = expect_simple(&script.body[0]);
let (tools, operator, operand, colon_variant) =
expect_indirect_expansion_part(&command.args[0].parts[0].kind);
assert_eq!(tools.name.as_str(), "tools");
assert!(!colon_variant);
assert!(operator.is_none());
assert!(operand.is_none());
let subscript = expect_subscript(tools, input, "$target");
assert_eq!(subscript.syntax_text(input), "$target");
let (var, operator, operand, colon_variant) =
expect_indirect_expansion_part(&command.args[1].parts[0].kind);
assert_eq!(var.name.as_str(), "var");
assert!(!colon_variant);
assert!(operand.is_none());
match operator {
Some(ParameterOp::ReplaceAll {
pattern,
replacement,
replacement_word_ast,
}) => {
assert_eq!(pattern.render(input), "\n");
assert_eq!(replacement.slice(input), "' '");
assert_eq!(replacement_word_ast.render_syntax(input), "' '");
}
other => panic!("expected replace-all indirect expansion, got {other:?}"),
}
}
#[test]
fn test_parse_word_fragment_rebases_indirect_operator_spans() {
let source = "echo ${!var//$'\\n'/' '}";
let start = Position::new().advanced_by("echo ");
let span = Span::from_positions(start, start.advanced_by("${!var//$'\\n'/' '}"));
let word = Parser::parse_word_fragment(source, span.slice(source), span);
let parameter = expect_parameter(&word);
let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Indirect {
operator:
Some(ParameterOp::ReplaceAll {
replacement,
replacement_word_ast,
..
}),
..
}) = ¶meter.syntax
else {
panic!("expected indirect replacement operator");
};
assert_eq!(replacement.slice(source), "' '");
assert_eq!(replacement_word_ast.render_syntax(source), "' '");
assert_eq!(replacement_word_ast.span.slice(source), "' '");
}
#[test]
fn test_parse_indirect_special_hash_parameter() {
let input = "echo ${!#}\n";
let script = Parser::new(input).parse().unwrap().file;
let command = expect_simple(&script.body[0]);
let parameter = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Indirect {
reference,
operator,
operand,
..
}) = ¶meter.syntax
else {
panic!("expected indirect special-parameter expansion");
};
assert_eq!(reference.name.as_str(), "#");
assert!(operator.is_none());
assert!(operand.is_none());
}
#[test]
fn test_parse_special_hash_parameter_prefix_removal() {
let input = "echo ${##*/}\n";
let script = Parser::new(input).parse().unwrap().file;
let command = expect_simple(&script.body[0]);
let parameter = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
reference,
operator,
..
}) = ¶meter.syntax
else {
panic!("expected special-parameter operation expansion");
};
assert_eq!(reference.name.as_str(), "#");
match operator {
ParameterOp::RemovePrefixShort { pattern } => assert_eq!(pattern.render(input), "*/"),
other => panic!("expected short prefix removal, got {other:?}"),
}
}
#[test]
fn test_parse_length_of_special_parameters_after_hash_prefix() {
let input = "echo ${#-} ${#?} ${##}\n";
let script = Parser::new(input).parse().unwrap().file;
let command = expect_simple(&script.body[0]);
let first = expect_parameter(&command.args[0]);
assert!(matches!(
&first.syntax,
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Length { reference })
if reference.name.as_str() == "-"
));
let second = expect_parameter(&command.args[1]);
assert!(matches!(
&second.syntax,
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Length { reference })
if reference.name.as_str() == "?"
));
let third = expect_parameter(&command.args[2]);
assert!(matches!(
&third.syntax,
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Length { reference })
if reference.name.as_str() == "#"
));
}
#[test]
fn test_parse_special_zero_parameter_prefix_removal_inside_multiline_quote() {
let input = "\
usage=\"
Example:
${0##*/} github_repository
Terraform:
data \\\"external\\\" \\\"github_repos\\\" {
program = [\\\"/path/to/${0##*/}\\\", \\\"github_repository\\\"]
}
usage: ${0##*/} <resource_type>
\"
";
let script = Parser::new(input).parse().unwrap().file;
let command = expect_simple(&script.body[0]);
let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
let mut names = Vec::new();
collect_bourne_parameter_names(&word.parts, &mut names);
assert_eq!(names, vec!["0", "0", "0"]);
}
#[test]
fn test_non_zsh_dialect_parses_zsh_modifier_forms_as_zsh_parameters() {
let input = "print ${(%):-%x} ${(f)mapfile[$WD_CONFIG]//$HOME/~}\n";
let script = Parser::new(input).parse().unwrap().file;
let command = expect_simple(&script.body[0]);
let first = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Zsh(first) = &first.syntax else {
panic!("expected zsh parameter syntax");
};
assert!(matches!(first.target, ZshExpansionTarget::Empty));
assert!(matches!(
first.operation,
Some(ZshExpansionOperation::Defaulting {
kind: ZshDefaultingOp::UseDefault,
ref operand,
colon_variant: true,
..
}) if operand.slice(input) == "%x"
));
let first_operation = first.operation.as_ref().expect("expected zsh operation");
assert_eq!(
first_operation
.operand_word_ast()
.expect("expected defaulting operand word")
.render(input),
"%x"
);
let second = expect_parameter(&command.args[1]);
let ParameterExpansionSyntax::Zsh(second) = &second.syntax else {
panic!("expected zsh parameter syntax");
};
let ZshExpansionTarget::Reference(reference) = &second.target else {
panic!("expected zsh reference target");
};
assert_eq!(reference.name.as_str(), "mapfile");
let subscript = expect_subscript(reference, input, "$WD_CONFIG");
assert_eq!(subscript.syntax_text(input), "$WD_CONFIG");
assert!(matches!(
second.operation,
Some(ZshExpansionOperation::ReplacementOperation {
kind: ZshReplacementOp::ReplaceAll,
ref pattern,
replacement: Some(ref replacement),
..
}) if pattern.slice(input) == "$HOME" && replacement.slice(input) == "~"
));
let second_operation = second.operation.as_ref().expect("expected zsh operation");
assert_eq!(
second_operation
.pattern_word_ast()
.expect("expected replacement pattern word")
.render(input),
"$HOME"
);
assert_eq!(
second_operation
.replacement_word_ast()
.expect("expected replacement word")
.render(input),
"~"
);
}
#[test]
fn test_non_zsh_dialect_treats_dot_prefixed_parameter_forms_as_non_references() {
let input = "print ${.sh.file}\n";
let script = Parser::new(input).parse().unwrap().file;
let command = expect_simple(&script.body[0]);
let parameter = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Zsh(parameter) = ¶meter.syntax else {
panic!("expected zsh-style fallback parameter syntax");
};
let ZshExpansionTarget::Word(word) = ¶meter.target else {
panic!("expected non-reference word target");
};
assert_eq!(word.render(input), ".sh.file");
assert!(parameter.operation.is_none());
}
#[test]
fn test_compound_array_assignment_preserves_mixed_element_kinds() {
let input = "arr=(one [two]=2 [three]+=3 four)\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let assignment = &command.assignments[0];
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.kind, ArrayKind::Contextual);
assert_eq!(array.elements.len(), 4);
let ArrayElem::Sequential(first) = &array.elements[0] else {
panic!("expected first sequential element");
};
assert_eq!(first.span.slice(input), "one");
let ArrayElem::Keyed { key, value } = &array.elements[1] else {
panic!("expected keyed element");
};
assert_eq!(key.text.slice(input), "two");
assert_eq!(key.interpretation, SubscriptInterpretation::Contextual);
assert_eq!(value.span.slice(input), "2");
let ArrayElem::KeyedAppend { key, value } = &array.elements[2] else {
panic!("expected keyed append element");
};
assert_eq!(key.text.slice(input), "three");
assert_eq!(key.interpretation, SubscriptInterpretation::Contextual);
assert_eq!(value.span.slice(input), "3");
let ArrayElem::Sequential(last) = &array.elements[3] else {
panic!("expected trailing sequential element");
};
assert_eq!(last.span.slice(input), "four");
}
#[test]
fn test_assignment_append_and_keyed_append_stay_distinct() {
let input = "arr+=one\nassoc=(one [key]+=value)\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(first) = &script.body[0].command else {
panic!("expected first simple command");
};
assert!(first.assignments[0].append);
let AstCommand::Simple(second) = &script.body[1].command else {
panic!("expected second simple command");
};
assert!(!second.assignments[0].append);
let AssignmentValue::Compound(array) = &second.assignments[0].value else {
panic!("expected compound assignment");
};
assert!(matches!(array.elements[1], ArrayElem::KeyedAppend { .. }));
}
#[test]
fn test_assignment_target_mixed_subscript_and_compound_value_stay_structured() {
let input = "assoc[\"$key\"-suffix]=(\"$value\" plain)\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let assignment = &command.assignments[0];
assert_eq!(assignment.target.name.as_str(), "assoc");
let subscript = assignment
.target
.subscript
.as_ref()
.expect("expected target subscript");
assert_eq!(subscript.text.slice(input), "\"$key\"-suffix");
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound assignment value");
};
assert_eq!(array.elements.len(), 2);
let ArrayElem::Sequential(first) = &array.elements[0] else {
panic!("expected first sequential element");
};
assert_eq!(first.span.slice(input), "\"$value\"");
let ArrayElem::Sequential(second) = &array.elements[1] else {
panic!("expected second sequential element");
};
assert_eq!(second.span.slice(input), "plain");
}
#[test]
fn test_compound_array_value_words_track_top_level_unquoted_commas() {
let input = "\
arr=(
alpha,beta
head,$tail
[k]=v,
\"alpha,beta\"
$'alpha,beta'
$(printf %s 1,2)
<(printf %s 1,2)
>(printf %s 3,4)
${x/a,b/c}
${x/`echo }`/a,b}
${x/<(echo })/foo,bar}
$((1,2))
foo,{x,y},bar
)
";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let AssignmentValue::Compound(array) = &command.assignments[0].value else {
panic!("expected compound array assignment");
};
assert_eq!(array.elements.len(), 13, "{:#?}", array.elements);
let ArrayElem::Sequential(first) = &array.elements[0] else {
panic!("expected first sequential element");
};
assert!(first.has_top_level_unquoted_comma());
let ArrayElem::Sequential(second) = &array.elements[1] else {
panic!("expected second sequential element");
};
assert!(second.has_top_level_unquoted_comma());
let ArrayElem::Keyed { value, .. } = &array.elements[2] else {
panic!("expected keyed element");
};
assert!(value.has_top_level_unquoted_comma());
for (index, expected_span) in [
(3usize, "\"alpha,beta\""),
(4, "$'alpha,beta'"),
(5, "$(printf %s 1,2)"),
(6, "<(printf %s 1,2)"),
(7, ">(printf %s 3,4)"),
(8, "${x/a,b/c}"),
(9, "${x/`echo }`/a,b}"),
(10, "${x/<(echo })/foo,bar}"),
(11, "$((1,2))"),
] {
let ArrayElem::Sequential(value) = &array.elements[index] else {
panic!("expected sequential element at index {index}");
};
assert_eq!(value.span.slice(input), expected_span);
assert!(
!value.has_top_level_unquoted_comma(),
"unexpected comma flag for {}",
value.span.slice(input)
);
}
let ArrayElem::Sequential(last) = &array.elements[12] else {
panic!("expected trailing sequential element");
};
assert_eq!(last.span.slice(input), "foo,{x,y},bar");
assert!(last.has_top_level_unquoted_comma());
}
#[test]
fn test_compound_array_process_substitution_stays_typed_for_comma_detection() {
let input = "arr=(<(printf %s 1,2) >(printf %s 3,4))\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let AssignmentValue::Compound(array) = &command.assignments[0].value else {
panic!("expected compound array assignment");
};
for (index, is_input) in [(0usize, true), (1usize, false)] {
let ArrayElem::Sequential(value) = &array.elements[index] else {
panic!("expected sequential element at index {index}");
};
assert!(!value.has_top_level_unquoted_comma());
assert!(matches!(
&value.parts[0].kind,
WordPart::ProcessSubstitution { is_input: actual, .. } if *actual == is_input
));
}
}
#[test]
fn test_word_part_spans_track_mixed_expansions() {
let input = "echo pre${name:-fallback}$(printf hi)$((1+2))post\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let slices = top_level_part_slices(word, input);
assert_eq!(
slices,
vec![
"pre",
"${name:-fallback}",
"$(printf hi)",
"$((1+2))",
"post"
]
);
}
#[test]
fn test_word_part_spans_track_quoted_expansions() {
let input = "echo \"x$HOME$(pwd)y\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert_eq!(
top_level_part_slices(word, input),
vec!["\"x$HOME$(pwd)y\""]
);
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
let slices: Vec<&str> = parts.iter().map(|part| part.span.slice(input)).collect();
assert_eq!(slices, vec!["x", "$HOME", "$(pwd)", "y"]);
}
#[test]
fn test_mixed_segment_word_preserves_expansion_boundaries() {
let input = "echo foo\"$bar\"baz\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let slices = top_level_part_slices(word, input);
assert_eq!(slices, vec!["foo", "\"$bar\"", "baz"]);
let WordPart::DoubleQuoted { parts, .. } = &word.parts[1].kind else {
panic!("expected quoted middle segment");
};
assert!(matches!(parts.as_slice(), [part] if matches!(&part.kind, WordPart::Variable(_))));
}
#[test]
fn test_escaped_quote_literal_does_not_truncate_following_variable_name() {
let input = "echo \\\"$archname\\\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert_eq!(
top_level_part_slices(word, input),
vec!["\\\"", "$archname", "\\\""]
);
assert!(word_part_tree_contains_variable(&word.parts, "archname"));
assert!(!word_part_tree_contains_variable(&word.parts, "archnam"));
}
#[test]
fn test_assignment_value_preserves_mixed_quoted_boundaries() {
let input = "foo=\"$bar\"baz echo\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
let slices = top_level_part_slices(word, input);
assert!(!is_fully_quoted(word));
assert_eq!(slices, vec!["\"$bar\"", "baz"]);
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected quoted prefix");
};
assert!(matches!(parts.as_slice(), [part] if matches!(&part.kind, WordPart::Variable(_))));
}
#[test]
fn test_assignment_value_stays_quoted_when_entire_value_is_quoted() {
let input = "foo=\"$bar\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
let slices = top_level_part_slices(word, input);
assert!(is_fully_quoted(word));
assert_eq!(slices, vec!["\"$bar\""]);
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected fully quoted value");
};
assert!(matches!(parts.as_slice(), [part] if matches!(&part.kind, WordPart::Variable(_))));
}
#[test]
fn test_backtick_command_substitution_inside_double_quotes_preserves_syntax_form() {
let input = "echo \"pre `printf hi` post\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert!(is_fully_quoted(word));
assert_eq!(
top_level_part_slices(word, input),
vec!["\"pre `printf hi` post\""]
);
let WordPart::DoubleQuoted { parts, dollar } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
assert!(!dollar);
let slices: Vec<&str> = parts.iter().map(|part| part.span.slice(input)).collect();
assert_eq!(slices, vec!["pre ", "`printf hi`", " post"]);
let WordPart::CommandSubstitution {
body: commands,
syntax,
} = &parts[1].kind
else {
panic!("expected command substitution");
};
assert_eq!(*syntax, CommandSubstitutionSyntax::Backtick);
let inner = expect_simple(&commands[0]);
assert_eq!(inner.name.render(input), "printf");
assert_eq!(inner.args[0].render(input), "hi");
}
#[test]
fn test_backtick_command_substitution_inside_multiline_double_quotes_preserves_syntax_form() {
let input = "echo \"\\\n*** ERROR\n`cat lockfile 2>/dev/null`\n\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let WordPart::DoubleQuoted { parts, dollar } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
assert!(!dollar);
let slices: Vec<&str> = parts.iter().map(|part| part.span.slice(input)).collect();
assert_eq!(
slices,
vec!["\\\n*** ERROR\n", "`cat lockfile 2>/dev/null`", "\n"]
);
let WordPart::CommandSubstitution {
body: commands,
syntax,
} = &parts[1].kind
else {
panic!("expected command substitution");
};
assert_eq!(*syntax, CommandSubstitutionSyntax::Backtick);
let inner = expect_simple(&commands[0]);
assert_eq!(inner.name.render(input), "cat");
assert_eq!(inner.args[0].render(input), "lockfile");
assert_eq!(commands[0].redirects[0].fd, Some(2));
assert_eq!(
redirect_word_target(&commands[0].redirects[0]).render(input),
"/dev/null"
);
}
#[test]
fn test_backtick_assignments_after_quoted_heredoc_preserve_each_substitution() {
let input = "\
cat <<\\_ACEOF
Use these variables to override the choices made by `configure' or to help
it to find libraries and programs with nonstandard names/locations.
_ACEOF
ac_dir_suffix=/`$as_echo \"$ac_dir\" | sed 's|^\\.[\\\\/]||'`
ac_top_builddir_sub=`$as_echo \"$ac_dir_suffix\" | sed 's|/[^\\\\/]*|/..|g;s|/||'`
";
let script = Parser::new(input).parse().unwrap().file;
assert_eq!(script.body.len(), 3, "{:#?}", script.body);
let AstCommand::Simple(first) = &script.body[1].command else {
panic!("expected simple command, got {:#?}", script.body[1].command);
};
let AssignmentValue::Scalar(first_value) = &first.assignments[0].value else {
panic!("expected scalar assignment");
};
assert_eq!(
first_value
.parts
.iter()
.map(|part| part.span.slice(input))
.collect::<Vec<_>>(),
vec!["/", "`$as_echo \"$ac_dir\" | sed 's|^\\.[\\\\/]||'`"]
);
let WordPart::CommandSubstitution { syntax, body } = &first_value.parts[1].kind else {
panic!("expected backtick substitution");
};
assert_eq!(*syntax, CommandSubstitutionSyntax::Backtick);
assert!(!body.is_empty());
let AstCommand::Simple(second) = &script.body[2].command else {
panic!("expected simple command, got {:#?}", script.body[2].command);
};
let AssignmentValue::Scalar(second_value) = &second.assignments[0].value else {
panic!("expected scalar assignment");
};
assert_eq!(
second_value
.parts
.iter()
.map(|part| part.span.slice(input))
.collect::<Vec<_>>(),
vec!["`$as_echo \"$ac_dir_suffix\" | sed 's|/[^\\\\/]*|/..|g;s|/||'`"]
);
let WordPart::CommandSubstitution { syntax, body } = &second_value.parts[0].kind else {
panic!("expected backtick substitution");
};
assert_eq!(*syntax, CommandSubstitutionSyntax::Backtick);
assert!(!body.is_empty());
}
#[test]
fn test_dollar_paren_command_substitution_inside_double_quotes_preserves_nested_quoted_argument() {
let input = "echo \"$(cmd \"$arg\")\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert!(is_fully_quoted(word));
assert_eq!(
top_level_part_slices(word, input),
vec!["\"$(cmd \"$arg\")\""]
);
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
assert_eq!(
parts
.iter()
.map(|part| part.span.slice(input))
.collect::<Vec<_>>(),
vec!["$(cmd \"$arg\")"]
);
let WordPart::CommandSubstitution { body, syntax } = &parts[0].kind else {
panic!("expected command substitution");
};
assert_eq!(*syntax, CommandSubstitutionSyntax::DollarParen);
let inner = expect_simple(&body[0]);
assert_eq!(inner.name.render(input), "cmd");
assert_eq!(inner.args[0].render_syntax(input), "\"$arg\"");
}
#[test]
fn test_dollar_paren_command_substitution_inside_double_quotes_with_prefix_keeps_nested_spans_absolute()
{
let input = "echo \"pre $(echo hi) post\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
let WordPart::CommandSubstitution { body, syntax } = &parts[1].kind else {
panic!("expected command substitution");
};
assert_eq!(*syntax, CommandSubstitutionSyntax::DollarParen);
let inner = expect_simple(&body[0]);
assert_eq!(inner.name.render(input), "echo");
assert_eq!(inner.name.span.slice(input), "echo");
assert_eq!(inner.args[0].render(input), "hi");
}
#[test]
fn test_dollar_paren_command_substitution_inside_quoted_prefix_with_pipeline_keeps_nested_spans_absolute()
{
let input = "echo -n \"\\\"adp_$(echo $var | tr A-Z a-z)\\\": [\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.name.render(input), "echo");
assert_eq!(command.args[0].render(input), "-n");
let word = &command.args[1];
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
let WordPart::Literal(text) = &parts[0].kind else {
panic!("expected literal prefix");
};
assert_eq!(text.as_str(input, parts[0].span), "\"adp_");
let WordPart::CommandSubstitution { body, syntax } = &parts[1].kind else {
panic!("expected command substitution");
};
assert_eq!(*syntax, CommandSubstitutionSyntax::DollarParen);
let AstCommand::Binary(binary) = &body[0].command else {
panic!("expected pipeline");
};
let left = expect_simple(&binary.left);
assert_eq!(left.name.render(input), "echo");
assert_eq!(left.args[0].render(input), "$var");
let right = expect_simple(&binary.right);
assert_eq!(right.name.render(input), "tr");
assert_eq!(right.name.span.slice(input), "tr");
assert_eq!(right.args[0].render(input), "A-Z");
assert_eq!(right.args[1].render(input), "a-z");
}
#[test]
fn test_dollar_paren_command_substitution_inside_double_quotes_handles_nested_arithmetic_with_quoted_right_paren()
{
let input = "echo \"$(echo \"$(( $(printf ')') + 1 ))\")\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
let WordPart::CommandSubstitution { body, syntax } = &parts[0].kind else {
panic!("expected command substitution");
};
assert_eq!(*syntax, CommandSubstitutionSyntax::DollarParen);
let inner = expect_simple(&body[0]);
assert_eq!(inner.name.render(input), "echo");
assert_eq!(
inner.args[0].render_syntax(input),
"\"$(( $(printf ')') + 1 ))\""
);
}
#[test]
fn test_escaped_backticks_inside_double_quotes_stay_literal() {
let input = "echo \"pre \\`pwd\\` post\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert_eq!(word.render(input), "pre `pwd` post");
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
assert!(
!parts
.iter()
.any(|part| matches!(part.kind, WordPart::CommandSubstitution { .. }))
);
}
#[test]
fn test_escaped_backticks_after_escaped_backslashes_inside_double_quotes_stay_literal() {
let input = "echo \" echo Remember to run \\\\\\`updatedb\\\\'.\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert_eq!(word.render(input), " echo Remember to run \\`updatedb\\'.");
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
assert!(
!parts
.iter()
.any(|part| matches!(part.kind, WordPart::CommandSubstitution { .. }))
);
}
#[test]
fn test_process_substitution_like_text_inside_double_quotes_stays_literal() {
let input = "echo \"<(printf hi)\" \" >(printf bye)\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
for word in &command.args {
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
assert!(
!parts
.iter()
.any(|part| matches!(part.kind, WordPart::ProcessSubstitution { .. })),
"{:#?}",
parts
);
}
}
#[test]
fn test_process_substitution_like_regex_inside_nested_command_substitution_stays_literal() {
let input = "value=$(printf '%s\\n' \"<record_id>([^<]*)</record_id>\")\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let AssignmentValue::Scalar(value) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
let WordPart::CommandSubstitution { body, .. } = &value.parts[0].kind else {
panic!("expected command substitution");
};
let inner = expect_simple(&body[0]);
for word in &inner.args {
assert!(
!word
.parts
.iter()
.any(|part| matches!(part.kind, WordPart::ProcessSubstitution { .. })),
"{:#?}",
word.parts
);
}
}
#[test]
fn test_process_substitution_like_regex_inside_nested_pipeline_command_substitution_stays_literal()
{
let input = "_record_id=$(echo \"$response\" | _egrep_o \"<record_id>([^<]*)</record_id><type>TXT</type><host>$fulldomain</host>\" | _egrep_o \"<record_id>([^<]*)</record_id>\" | sed -r \"s/<record_id>([^<]*)<\\/record_id>/\\1/\" | tail -n 1)\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let AssignmentValue::Scalar(value) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
let WordPart::CommandSubstitution { body, .. } = &value.parts[0].kind else {
panic!("expected command substitution");
};
fn word_has_process_substitution(word: &Word) -> bool {
word.parts.iter().any(|part| match &part.kind {
WordPart::ProcessSubstitution { .. } => true,
WordPart::DoubleQuoted { parts, .. } => parts
.iter()
.any(|part| matches!(part.kind, WordPart::ProcessSubstitution { .. })),
_ => false,
})
}
fn stmt_has_process_substitution(stmt: &Stmt) -> bool {
command_has_process_substitution(&stmt.command)
}
fn command_has_process_substitution(command: &AstCommand) -> bool {
match command {
AstCommand::Simple(command) => command.args.iter().any(word_has_process_substitution),
AstCommand::Binary(binary)
if matches!(binary.op, BinaryOp::Pipe | BinaryOp::PipeAll) =>
{
stmt_has_process_substitution(&binary.left)
|| stmt_has_process_substitution(&binary.right)
}
_ => false,
}
}
for stmt in body.iter() {
assert!(!stmt_has_process_substitution(stmt), "{:#?}", stmt);
}
}
#[test]
fn test_escaped_process_substitution_like_text_stays_literal() {
let input = "echo \\<(printf hi) \\>(printf bye)\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
for word in &command.args {
assert!(
!word
.parts
.iter()
.any(|part| matches!(part.kind, WordPart::ProcessSubstitution { .. })),
"{:#?}",
word.parts
);
}
}
#[test]
fn test_escaped_backticks_stay_literal_unquoted() {
let input = "echo \\`pwd\\`\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert_eq!(word.render(input), "`pwd`");
assert_eq!(word.render_syntax(input), "\\`pwd\\`");
assert!(matches!(
word.parts.as_slice(),
[WordPartNode {
kind: WordPart::Literal(text),
..
}] if text.is_source_backed() && text.as_str(input, word.parts[0].span) == "`pwd`"
));
}
#[test]
fn test_unquoted_backtick_substitution_can_contain_spaces() {
let input = "commands=(`pyenv-commands --sh`)\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let AssignmentValue::Compound(array) = &command.assignments[0].value else {
panic!("expected compound assignment");
};
assert_eq!(array.elements.len(), 1);
let ArrayElem::Sequential(word) = &array.elements[0] else {
panic!("expected sequential element");
};
assert_eq!(word.render(input), "`pyenv-commands --sh`");
let WordPart::CommandSubstitution { body, syntax } = &word.parts[0].kind else {
panic!("expected backtick substitution");
};
assert_eq!(*syntax, CommandSubstitutionSyntax::Backtick);
assert_eq!(body.len(), 1);
let inner = expect_simple(&body[0]);
assert_eq!(inner.name.render(input), "pyenv-commands");
assert_eq!(inner.args[0].render(input), "--sh");
}
#[test]
fn test_compound_array_keeps_quoted_pipelined_heredoc_substitution_as_one_element() {
let input = r#"# shellcheck shell=bash
project=owner/repo
graphql_request=(
-X POST
-d "$(
cat <<-EOF | tr '\n' ' '
{
"query": "query {
repository(owner: \"${project%/*}\", name: \"${project##*/}\") {
refs(refPrefix: \"refs/tags/\")
}
}"
}
EOF
)"
)
"#;
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[1].command else {
panic!("expected simple command");
};
let AssignmentValue::Compound(array) = &command.assignments[0].value else {
panic!("expected compound assignment");
};
assert_eq!(array.elements.len(), 4);
let rendered = array
.elements
.iter()
.map(|element| match element {
ArrayElem::Sequential(word) => word.span.slice(input).to_owned(),
_ => panic!("expected sequential array element"),
})
.collect::<Vec<_>>();
assert_eq!(
rendered,
vec![
"-X",
"POST",
"-d",
"\"$(\n cat <<-EOF | tr '\\n' ' '\n {\n \"query\": \"query {\n repository(owner: \\\"${project%/*}\\\", name: \\\"${project##*/}\\\") {\n refs(refPrefix: \\\"refs/tags/\\\")\n }\n }\"\n }\nEOF\n )\"",
]
);
}
#[test]
fn test_brace_syntax_marks_unquoted_expansion_candidates() {
let list_input = "{a,b}";
let list = Parser::parse_word_string(list_input);
assert_eq!(brace_slices(&list, list_input), vec!["{a,b}"]);
assert_eq!(
list.brace_syntax(),
&[BraceSyntax {
kind: BraceSyntaxKind::Expansion(BraceExpansionKind::CommaList),
span: list.span,
quote_context: BraceQuoteContext::Unquoted,
}]
);
assert!(list.has_active_brace_expansion());
let sequence_input = "{1..3}";
let sequence = Parser::parse_word_string(sequence_input);
assert_eq!(brace_slices(&sequence, sequence_input), vec!["{1..3}"]);
assert_eq!(
sequence.brace_syntax()[0].kind,
BraceSyntaxKind::Expansion(BraceExpansionKind::Sequence)
);
assert!(sequence.brace_syntax()[0].expands());
}
#[test]
fn test_brace_syntax_marks_literal_and_quoted_brace_forms() {
let literal_input = "HEAD@{1}";
let literal = Parser::parse_word_string(literal_input);
assert_eq!(brace_slices(&literal, literal_input), vec!["{1}"]);
assert_eq!(literal.brace_syntax()[0].kind, BraceSyntaxKind::Literal);
assert!(literal.brace_syntax()[0].treated_literally());
assert!(!literal.has_active_brace_expansion());
let quoted_input = "\"{a,b}\"";
let quoted = Parser::parse_word_string(quoted_input);
assert_eq!(brace_slices("ed, quoted_input), vec!["{a,b}"]);
assert_eq!(
quoted.brace_syntax()[0].kind,
BraceSyntaxKind::Expansion(BraceExpansionKind::CommaList)
);
assert_eq!(
quoted.brace_syntax()[0].quote_context,
BraceQuoteContext::DoubleQuoted
);
assert!(quoted.brace_syntax()[0].treated_literally());
assert!(!quoted.has_active_brace_expansion());
}
#[test]
fn test_brace_syntax_treats_whitespace_and_quoted_lists_as_literal() {
for input in ["{443, 8443}", "{tcp, udp}"] {
let word = Parser::parse_word_string(input);
assert_eq!(brace_slices(&word, input), vec![input]);
assert_eq!(word.brace_syntax()[0].kind, BraceSyntaxKind::Literal);
assert!(word.brace_syntax()[0].treated_literally());
assert!(!word.has_active_brace_expansion());
}
let quoted_assembly = Parser::parse_word_string(r#"{"$mix_port, $redir_port, $tproxy_port"}"#);
assert!(quoted_assembly.brace_syntax().is_empty());
assert!(!quoted_assembly.has_active_brace_expansion());
}
#[test]
fn test_brace_syntax_preserves_brace_expansion_suffix_forms() {
for input in [
"{a,b}}",
"{~,~root}/pwd",
"\"\"{~,~root}/pwd",
"\\{~,~root}/pwd",
] {
let word = Parser::parse_word_string(input);
assert_eq!(word.render_syntax(input), input);
if input == "\\{~,~root}/pwd" {
assert_eq!(brace_slices(&word, input), Vec::<&str>::new());
} else {
let expected = if input == "{a,b}}" {
"{a,b}"
} else {
"{~,~root}"
};
assert_eq!(brace_slices(&word, input), vec![expected]);
}
}
}
#[test]
fn test_brace_syntax_marks_template_placeholders_inside_quotes() {
let input = "\"$root/pkg/{{name}}/bin/{{cmd}}\"";
let word = Parser::parse_word_string(input);
assert_eq!(brace_slices(&word, input), vec!["{{name}}", "{{cmd}}"]);
assert_eq!(word.brace_syntax().len(), 2);
assert!(
word.brace_syntax()
.iter()
.all(|brace| brace.kind == BraceSyntaxKind::TemplatePlaceholder)
);
assert!(
word.brace_syntax()
.iter()
.all(|brace| brace.quote_context == BraceQuoteContext::DoubleQuoted)
);
}
#[test]
fn test_brace_syntax_marks_nested_expansions_separately() {
let input = "{EGL,GLES{,2,3}}";
let word = Parser::parse_word_string(input);
assert_eq!(
brace_slices(&word, input),
vec!["{EGL,GLES{,2,3}}", "{,2,3}"]
);
assert!(word.brace_syntax().iter().all(|brace| brace.expands()));
}
#[test]
fn test_brace_syntax_marks_all_nested_comma_lists() {
let input =
"usr/include/{sys/{capability,shm,sem},{glob,iconv,spawn,zlib,zconf},KHR/khrplatform}.h";
let word = Parser::parse_word_string(input);
assert_eq!(
brace_slices(&word, input),
vec![
"{sys/{capability,shm,sem},{glob,iconv,spawn,zlib,zconf},KHR/khrplatform}",
"{capability,shm,sem}",
"{glob,iconv,spawn,zlib,zconf}",
]
);
}
#[test]
fn test_brace_syntax_spans_quoted_members_inside_unquoted_lists() {
let input =
"\"$TERMUX_GODIR\"/{bin,src,doc,lib,\"pkg/tool/$TERMUX_GOLANG_DIRNAME\",pkg/include}";
let word = Parser::parse_word_string(input);
assert_eq!(
brace_slices(&word, input),
vec!["{bin,src,doc,lib,\"pkg/tool/$TERMUX_GOLANG_DIRNAME\",pkg/include}"]
);
assert!(word.has_active_brace_expansion());
}
#[test]
fn test_brace_syntax_does_not_treat_double_open_braces_as_template_placeholders_when_they_expand() {
let input = "lib{{pthread,resolv,ffi_pic}.a,rt.so}";
let word = Parser::parse_word_string(input);
assert_eq!(
brace_slices(&word, input),
vec![
"{{pthread,resolv,ffi_pic}.a,rt.so}",
"{pthread,resolv,ffi_pic}"
]
);
assert!(word.brace_syntax().iter().all(|brace| brace.expands()));
}
#[test]
fn test_brace_syntax_ignores_escaped_unquoted_braces() {
let word = Parser::parse_word_string("\\{a,b\\}");
assert!(word.brace_syntax().is_empty());
assert!(!word.has_active_brace_expansion());
}
#[test]
fn test_brace_syntax_keeps_ansi_c_escaped_quotes_inside_single_quoted_regions() {
let input = r#"$'foo\'{a,b}'"#;
let word = Parser::parse_word_string(input);
assert_eq!(brace_slices(&word, input), vec!["{a,b}"]);
assert!(
word.brace_syntax()
.iter()
.all(|brace| brace.treated_literally())
);
assert!(!word.has_active_brace_expansion());
}
#[test]
fn test_brace_syntax_does_not_merge_dots_across_skipped_expansion_parts() {
let input = "{1.$x.3}";
let word = Parser::parse_word_string(input);
assert_eq!(brace_slices(&word, input), vec!["{1.$x.3}"]);
assert!(
word.brace_syntax()
.iter()
.all(|brace| brace.treated_literally())
);
assert!(!word.has_active_brace_expansion());
}
#[test]
fn test_brace_syntax_ignores_quoted_closers_when_balancing_cross_part_lists() {
let input = r#"{"}",a}"#;
let word = Parser::parse_word_string(input);
assert_eq!(brace_slices(&word, input), vec![input]);
assert_eq!(
word.brace_syntax()[0].kind,
BraceSyntaxKind::Expansion(BraceExpansionKind::CommaList)
);
assert!(word.has_active_brace_expansion());
}
#[test]
fn test_parse_word_with_mid_word_brace_segment_ignores_quoted_closers() {
let input = "echo {\"}\",a}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.args.len(), 1);
assert_eq!(command.args[0].span.slice(input), r#"{"}",a}"#);
assert_eq!(brace_slices(&command.args[0], input), vec![r#"{"}",a}"#]);
assert!(command.args[0].has_active_brace_expansion());
}
#[test]
fn test_parse_brace_expansion_with_single_quoted_backslash_member_keeps_following_args() {
let input = "echo {'a\\',b} next\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.args.len(), 2);
assert_eq!(command.args[0].span.slice(input), r#"{'a\',b}"#);
assert_eq!(command.args[1].span.slice(input), "next");
assert!(command.args[0].has_active_brace_expansion());
}
#[test]
fn test_brace_syntax_handles_deeply_nested_braces_without_recursion() {
let depth = 8192usize;
let mut input = String::with_capacity(depth * 3 + 2);
for _ in 0..depth {
input.push('{');
input.push('a');
}
input.push(',');
input.push('b');
for _ in 0..depth {
input.push('}');
}
let word = Parser::parse_word_string(&input);
assert_eq!(word.brace_syntax().len(), depth);
assert_eq!(
word.brace_syntax()
.iter()
.filter(|brace| brace.expands())
.count(),
1
);
assert_eq!(brace_slices(&word, &input).last().copied(), Some("{a,b}"));
assert!(word.has_active_brace_expansion());
}
#[test]
fn test_dollar_quoted_words_preserve_quote_variants() {
let input = "printf $'line\\n' $\"prefix $HOME\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.args.len(), 2);
let ansi = &command.args[0];
assert!(is_fully_quoted(ansi));
assert_eq!(top_level_part_slices(ansi, input), vec!["$'line\\n'"]);
let WordPart::SingleQuoted { value, dollar } = &ansi.parts[0].kind else {
panic!("expected single-quoted word");
};
assert!(*dollar);
assert_eq!(value.slice(input), "line\n");
let translated = &command.args[1];
assert!(is_fully_quoted(translated));
assert_eq!(
top_level_part_slices(translated, input),
vec!["$\"prefix $HOME\""]
);
let WordPart::DoubleQuoted { parts, dollar } = &translated.parts[0].kind else {
panic!("expected double-quoted word");
};
assert!(*dollar);
let slices: Vec<&str> = parts.iter().map(|part| part.span.slice(input)).collect();
assert_eq!(slices, vec!["prefix ", "$HOME"]);
assert!(matches!(parts[1].kind, WordPart::Variable(ref name) if name == "HOME"));
}
#[test]
fn test_dollar_quotes_stay_literal_inside_double_quotes() {
let input = "printf \"%s\" \"$'inner'\" \"$\\\"inner\\\"\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.args.len(), 3);
for arg in &command.args[1..] {
let WordPart::DoubleQuoted { parts, .. } = &arg.parts[0].kind else {
panic!("expected double-quoted word");
};
assert_eq!(arg.render_syntax(input), arg.span.slice(input));
assert!(
!parts.iter().any(|part| matches!(
part.kind,
WordPart::SingleQuoted { .. } | WordPart::DoubleQuoted { dollar: true, .. }
)),
"double-quoted contents should keep nested dollar-quote syntax literal: {parts:#?}"
);
}
}
#[test]
fn test_word_part_spans_track_nested_array_expansions() {
let input = "echo ${arr[$RANDOM % ${#arr[@]}]}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert_eq!(word.parts.len(), 1);
assert_eq!(
word.part_span(0).unwrap().slice(input),
"${arr[$RANDOM % ${#arr[@]}]}"
);
let reference = array_access_reference(&word.parts[0].kind).expect("expected array access");
let subscript = reference.subscript.as_ref().expect("expected subscript");
assert!(subscript.is_source_backed());
assert_eq!(subscript.text.slice(input), "$RANDOM % ${#arr[@]}");
}
#[test]
fn test_word_part_spans_track_parenthesized_arithmetic_expansion() {
let input = "echo $((a <= (1 || 2)))\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert_eq!(word.parts.len(), 1);
assert_eq!(
word.part_span(0).unwrap().slice(input),
"$((a <= (1 || 2)))"
);
let WordPart::ArithmeticExpansion {
expression,
expression_ast,
syntax,
..
} = &word.parts[0].kind
else {
panic!("expected arithmetic expansion");
};
assert_eq!(*syntax, ArithmeticExpansionSyntax::DollarParenParen);
assert!(expression.is_source_backed());
assert_eq!(expression.slice(input), "a <= (1 || 2)");
let expr = expression_ast
.as_ref()
.expect("expected typed arithmetic AST");
let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
panic!("expected binary arithmetic expression");
};
assert_eq!(*op, ArithmeticBinaryOp::LessThanOrEqual);
expect_variable(left, "a");
let ArithmeticExpr::Parenthesized { expression } = &right.kind else {
panic!("expected parenthesized right operand");
};
let ArithmeticExpr::Binary {
left: inner_left,
op: inner_op,
right: inner_right,
} = &expression.kind
else {
panic!("expected logical-or inside parentheses");
};
assert_eq!(*inner_op, ArithmeticBinaryOp::LogicalOr);
expect_number(inner_left, input, "1");
expect_number(inner_right, input, "2");
}
#[test]
fn test_word_part_spans_track_nested_arithmetic_expansion() {
let input = "echo $(((a) + ((b))))\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
assert_eq!(word.parts.len(), 1);
assert_eq!(word.part_span(0).unwrap().slice(input), "$(((a) + ((b))))");
let WordPart::ArithmeticExpansion {
expression,
expression_ast,
syntax,
..
} = &word.parts[0].kind
else {
panic!("expected arithmetic expansion");
};
assert_eq!(*syntax, ArithmeticExpansionSyntax::DollarParenParen);
assert!(expression.is_source_backed());
assert_eq!(expression.slice(input), "(a) + ((b))");
let expr = expression_ast
.as_ref()
.expect("expected typed arithmetic AST");
let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
panic!("expected binary arithmetic expression");
};
assert_eq!(*op, ArithmeticBinaryOp::Add);
assert!(matches!(left.kind, ArithmeticExpr::Parenthesized { .. }));
assert!(matches!(right.kind, ArithmeticExpr::Parenthesized { .. }));
}
#[test]
fn test_arithmetic_expansion_inside_double_quotes_preserves_legacy_and_modern_syntax() {
let input = "echo \"$((1 + 2))\" \"$[3 + 4]\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.args.len(), 2);
let modern = &command.args[0];
assert!(is_fully_quoted(modern));
let WordPart::DoubleQuoted { parts, dollar } = &modern.parts[0].kind else {
panic!("expected double-quoted modern arithmetic");
};
assert!(!dollar);
assert_eq!(parts[0].span.slice(input), "$((1 + 2))");
let WordPart::ArithmeticExpansion {
expression,
expression_ast,
syntax,
..
} = &parts[0].kind
else {
panic!("expected arithmetic expansion");
};
assert_eq!(*syntax, ArithmeticExpansionSyntax::DollarParenParen);
assert!(expression.is_source_backed());
assert_eq!(expression.slice(input), "1 + 2");
let expr = expression_ast
.as_ref()
.expect("expected typed arithmetic AST");
let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
panic!("expected binary arithmetic expression");
};
assert_eq!(*op, ArithmeticBinaryOp::Add);
expect_number(left, input, "1");
expect_number(right, input, "2");
let legacy = &command.args[1];
assert!(is_fully_quoted(legacy));
let WordPart::DoubleQuoted { parts, dollar } = &legacy.parts[0].kind else {
panic!("expected double-quoted legacy arithmetic");
};
assert!(!dollar);
assert_eq!(parts[0].span.slice(input), "$[3 + 4]");
let WordPart::ArithmeticExpansion {
expression,
expression_ast,
syntax,
..
} = &parts[0].kind
else {
panic!("expected arithmetic expansion");
};
assert_eq!(*syntax, ArithmeticExpansionSyntax::LegacyBracket);
assert!(expression.is_source_backed());
assert_eq!(expression.slice(input), "3 + 4");
let expr = expression_ast
.as_ref()
.expect("expected typed arithmetic AST");
let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
panic!("expected binary arithmetic expression");
};
assert_eq!(*op, ArithmeticBinaryOp::Add);
expect_number(left, input, "3");
expect_number(right, input, "4");
}
#[test]
fn test_arithmetic_expansion_after_escaped_quote_inside_double_quotes_stays_live() {
let input = "echo \"prefix \\\" $((++attempt))\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
let WordPart::ArithmeticExpansion {
expression,
expression_ast,
syntax,
..
} = &parts[1].kind
else {
panic!("expected arithmetic expansion after escaped quote");
};
assert_eq!(*syntax, ArithmeticExpansionSyntax::DollarParenParen);
assert!(expression.is_source_backed());
assert_eq!(expression.slice(input), "++attempt");
let ArithmeticExpr::Unary { op, expr } = &expression_ast
.as_ref()
.expect("expected typed arithmetic AST")
.kind
else {
panic!("expected prefix update expression");
};
assert_eq!(*op, ArithmeticUnaryOp::PreIncrement);
expect_variable(expr, "attempt");
}
#[test]
fn test_arithmetic_expansion_keeps_parameter_in_numeric_literal_shell_word() {
let input = "echo $((10#$HOUR)) $((0x$byte6 + summand))\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let base_literal = &command.args[0];
let WordPart::ArithmeticExpansion {
expression_ast,
syntax,
..
} = &base_literal.parts[0].kind
else {
panic!("expected base-literal arithmetic expansion");
};
assert_eq!(*syntax, ArithmeticExpansionSyntax::DollarParenParen);
let ArithmeticExpr::ShellWord(word) = &expression_ast
.as_ref()
.expect("expected typed arithmetic AST")
.kind
else {
panic!("expected shell-word numeric literal");
};
assert!(word_part_tree_contains_variable(&word.parts, "HOUR"));
let mixed = &command.args[1];
let WordPart::ArithmeticExpansion { expression_ast, .. } = &mixed.parts[0].kind else {
panic!("expected mixed arithmetic expansion");
};
let ArithmeticExpr::Binary { left, op, right } = &expression_ast
.as_ref()
.expect("expected typed arithmetic AST")
.kind
else {
panic!("expected arithmetic addition");
};
assert_eq!(*op, ArithmeticBinaryOp::Add);
let ArithmeticExpr::ShellWord(word) = &left.kind else {
panic!("expected shell-word numeric literal on left");
};
assert!(word_part_tree_contains_variable(&word.parts, "byte6"));
expect_variable(right, "summand");
}
#[test]
fn test_word_part_spans_track_unquoted_legacy_arithmetic_expansion() {
let input = "i=$[ $i - 1 ]\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert!(command.args.is_empty());
let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
let WordPart::ArithmeticExpansion {
syntax, expression, ..
} = &word.parts[0].kind
else {
panic!("expected arithmetic expansion");
};
assert_eq!(*syntax, ArithmeticExpansionSyntax::LegacyBracket);
assert!(expression.is_source_backed());
assert_eq!(word.parts[0].span.slice(input), "$[ $i - 1 ]");
}
#[test]
fn test_parameter_expansion_operand_stays_source_backed() {
let input = "echo ${var:-$(pwd)}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let (_, _, operand) = expect_parameter_operation_part(&word.parts[0].kind);
let operand = operand.expect("expected operand");
assert!(operand.is_source_backed());
assert_eq!(operand.slice(input), "$(pwd)");
let parameter = expect_parameter(word);
let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
operand_word_ast: Some(operand_word_ast),
..
}) = ¶meter.syntax
else {
panic!("expected unified bourne operation");
};
assert_eq!(operand_word_ast.render(input), "$(pwd)");
assert_eq!(operand_word_ast.span.slice(input), "$(pwd)");
}
#[test]
fn test_parameter_default_operand_keeps_nested_command_substitution_spans() {
let input = "NUMJOBS=${NUMJOBS:-\\\" -j $(expr $(nproc) + 1) \\\"}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
let parameter = expect_parameter(word);
let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
operand_word_ast: Some(operand_word_ast),
..
}) = ¶meter.syntax
else {
panic!("expected parameter operation");
};
let body = operand_word_ast
.parts
.iter()
.find_map(|part| match &part.kind {
WordPart::CommandSubstitution { body, .. } => Some(body),
_ => None,
})
.expect("expected outer command substitution");
let expr = expect_simple(&body[0]);
assert_eq!(expr.name.render(input), "expr");
let WordPart::CommandSubstitution { .. } = &expr.args[0].parts[0].kind else {
panic!("expected nested command substitution");
};
assert_eq!(expr.args[0].parts[0].span.slice(input), "$(nproc)");
}
#[test]
fn test_parameter_quoted_default_operand_keeps_nested_command_substitution_spans() {
let input = "NUMJOBS=${NUMJOBS:-\" -j $(expr $(nproc) + 1) \"}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
let parameter = expect_parameter(word);
let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
operand_word_ast: Some(operand_word_ast),
..
}) = ¶meter.syntax
else {
panic!("expected parameter operation");
};
let WordPart::DoubleQuoted { parts, .. } = &operand_word_ast.parts[0].kind else {
panic!("expected quoted default operand");
};
let body = parts
.iter()
.find_map(|part| match &part.kind {
WordPart::CommandSubstitution { body, .. } => Some(body),
_ => None,
})
.expect("expected outer command substitution");
let expr = expect_simple(&body[0]);
assert_eq!(expr.name.render(input), "expr");
let WordPart::CommandSubstitution { .. } = &expr.args[0].parts[0].kind else {
panic!("expected nested command substitution");
};
assert_eq!(expr.args[0].parts[0].span.slice(input), "$(nproc)");
}
#[test]
fn test_parameter_default_operand_does_not_absorb_later_double_quoted_expansion() {
let input = "echo \"${home:-\"${default}\"}'${foo}'\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let [
WordPartNode {
kind: WordPart::DoubleQuoted { parts, .. },
..
},
] = word.parts.as_slice()
else {
panic!("expected one double-quoted argument");
};
let [first, second, third, fourth] = parts.as_slice() else {
panic!("expected parameter, quote, parameter, quote parts: {parts:#?}");
};
let WordPart::Parameter(parameter) = &first.kind else {
panic!("expected leading parameter expansion, got {:?}", first.kind);
};
let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
operand: Some(operand),
operand_word_ast: Some(operand_word_ast),
..
}) = ¶meter.syntax
else {
panic!("expected parameter operation with parsed operand");
};
assert_eq!(operand.slice(input), "\"${default}\"");
assert_eq!(operand_word_ast.render(input), "${default}");
let [
WordPartNode {
kind:
WordPart::DoubleQuoted {
parts: operand_parts,
..
},
..
},
] = operand_word_ast.parts.as_slice()
else {
panic!("expected quoted operand word");
};
let [
WordPartNode {
kind: WordPart::Parameter(parameter),
..
},
] = operand_parts.as_slice()
else {
panic!("expected nested parameter expansion inside operand");
};
let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference }) =
¶meter.syntax
else {
panic!("expected operand to preserve the nested default expansion");
};
assert_eq!(reference.name.as_str(), "default");
assert_eq!(second.span.slice(input), "'");
let WordPart::Parameter(parameter) = &third.kind else {
panic!("expected later parameter expansion, got {:?}", third.kind);
};
let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference }) =
¶meter.syntax
else {
panic!("expected simple access expansion");
};
assert_eq!(reference.name.as_str(), "foo");
assert_eq!(fourth.span.slice(input), "'");
}
#[test]
fn test_nested_default_operand_keeps_quoted_right_brace_literal() {
let input = "echo \"${outer:-${inner:-\"}\"}}\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let [
WordPartNode {
kind: WordPart::DoubleQuoted { parts, .. },
..
},
] = word.parts.as_slice()
else {
panic!("expected one double-quoted argument");
};
let [
WordPartNode {
kind: WordPart::Parameter(parameter),
..
},
] = parts.as_slice()
else {
panic!("expected one parameter expansion in outer quotes");
};
let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
operand: Some(operand),
operand_word_ast: Some(operand_word_ast),
..
}) = ¶meter.syntax
else {
panic!("expected outer parameter operation with parsed operand");
};
assert_eq!(operand.slice(input), "${inner:-\"}\"}");
let [
WordPartNode {
kind: WordPart::Parameter(parameter),
..
},
] = operand_word_ast.parts.as_slice()
else {
panic!("expected nested parameter expansion in outer operand");
};
let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
operand: Some(operand),
operand_word_ast: Some(operand_word_ast),
..
}) = ¶meter.syntax
else {
panic!("expected inner parameter operation with parsed operand");
};
assert_eq!(operand.slice(input), "\"}\"");
let [
WordPartNode {
kind:
WordPart::DoubleQuoted {
parts: quoted_parts,
..
},
..
},
] = operand_word_ast.parts.as_slice()
else {
panic!("expected quoted inner operand word");
};
let [literal] = quoted_parts.as_slice() else {
panic!("expected quoted operand to contain a single literal brace");
};
assert_eq!(literal.span.slice(input), "}");
}
#[test]
fn test_literal_brace_before_quoted_nested_default_does_not_absorb_later_expansion() {
let input = "echo \"${outer:-{{\"${inner}}\"}}${after}\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let [
WordPartNode {
kind: WordPart::DoubleQuoted { parts, .. },
..
},
] = word.parts.as_slice()
else {
panic!("expected one double-quoted argument");
};
let [first, second] = parts.as_slice() else {
panic!("expected outer and later parameter expansions: {parts:#?}");
};
let WordPart::Parameter(parameter) = &first.kind else {
panic!("expected leading parameter expansion, got {:?}", first.kind);
};
let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
operand: Some(operand),
operand_word_ast: Some(operand_word_ast),
..
}) = ¶meter.syntax
else {
panic!("expected outer parameter operation with parsed operand");
};
assert_eq!(operand.slice(input), "{{\"${inner}}\"}");
let [literal_prefix, quoted_suffix, literal_suffix] = operand_word_ast.parts.as_slice() else {
panic!("expected literal, quoted, literal operand structure: {operand_word_ast:#?}");
};
assert_eq!(literal_prefix.span.slice(input), "{{");
let WordPart::DoubleQuoted {
parts: quoted_parts,
..
} = "ed_suffix.kind
else {
panic!(
"expected quoted middle segment in outer operand, got {:?}",
quoted_suffix.kind
);
};
let [nested, quoted_literal_suffix] = quoted_parts.as_slice() else {
panic!("expected nested parameter and literal brace in quoted suffix");
};
let WordPart::Parameter(parameter) = &nested.kind else {
panic!("expected nested parameter expansion, got {:?}", nested.kind);
};
let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference }) =
¶meter.syntax
else {
panic!("expected inner access expansion");
};
assert_eq!(reference.name.as_str(), "inner");
assert_eq!(quoted_literal_suffix.span.slice(input), "}");
assert_eq!(literal_suffix.span.slice(input), "}");
let WordPart::Parameter(parameter) = &second.kind else {
panic!("expected later parameter expansion, got {:?}", second.kind);
};
let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference }) =
¶meter.syntax
else {
panic!("expected later access expansion");
};
assert_eq!(reference.name.as_str(), "after");
}
#[test]
fn test_array_target_parameter_operations_normalize_to_bourne_operations() {
let input = "echo ${arr[0]//x/y} ${arr[@],,} ${arr[1]^^pattern}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let (replace_reference, replace_operator, _) =
expect_parameter_operation_part(&command.args[0].parts[0].kind);
expect_subscript(replace_reference, input, "0");
assert!(matches!(replace_operator, ParameterOp::ReplaceAll { .. }));
let (lower_reference, lower_operator, lower_operand) =
expect_parameter_operation_part(&command.args[1].parts[0].kind);
expect_subscript(lower_reference, input, "@");
assert!(matches!(lower_operator, ParameterOp::LowerAll));
assert!(lower_operand.is_none());
let (upper_reference, upper_operator, upper_operand) =
expect_parameter_operation_part(&command.args[2].parts[0].kind);
expect_subscript(upper_reference, input, "1");
assert!(matches!(upper_operator, ParameterOp::UpperAll));
assert_eq!(
upper_operand
.expect("expected case-modification operand")
.slice(input),
"pattern"
);
}
#[test]
fn test_case_modification_operands_consume_nested_parameter_expansions() {
let input = "echo ${name^^${pat}} ${arr[1],,${pat}}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let (_, upper_operator, upper_operand) =
expect_parameter_operation_part(&command.args[0].parts[0].kind);
assert!(matches!(upper_operator, ParameterOp::UpperAll));
assert_eq!(
upper_operand
.expect("expected upper case-modification operand")
.slice(input),
"${pat}"
);
let (lower_reference, lower_operator, lower_operand) =
expect_parameter_operation_part(&command.args[1].parts[0].kind);
expect_subscript(lower_reference, input, "1");
assert!(matches!(lower_operator, ParameterOp::LowerAll));
assert_eq!(
lower_operand
.expect("expected lower case-modification operand")
.slice(input),
"${pat}"
);
}
#[test]
fn test_parameter_expansion_trim_operand_accepts_literal_left_brace_after_multiline_quote() {
let input = "dns_servercow_info='ServerCow.de\nSite: ServerCow.de\n'\n\nf(){\n if true; then\n txtvalue_old=${response#*{\\\"name\\\":\\\"\"$_sub_domain\"\\\",\\\"ttl\\\":20,\\\"type\\\":\\\"TXT\\\",\\\"content\\\":\\\"}\n fi\n}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Function(function) = &script.body[1].command else {
panic!("expected function definition");
};
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::BraceGroup(body) = compound else {
panic!("expected brace-group function body");
};
assert!(redirects.is_empty());
let (if_compound, redirects) = expect_compound(&body[0]);
let AstCompoundCommand::If(if_command) = if_compound else {
panic!("expected if command");
};
assert!(redirects.is_empty());
let command = expect_simple(&if_command.then_branch[0]);
let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
let ParameterOp::RemovePrefixShort { pattern } = operator else {
panic!("expected short-prefix trim operator");
};
assert!(pattern.render(input).contains("$_sub_domain"));
assert!(pattern.parts.iter().any(|part| {
matches!(
&part.kind,
PatternPart::Word(word)
if word_part_tree_contains_variable(&word.parts, "_sub_domain")
)
}));
}
#[test]
fn test_parameter_expansion_trim_operand_accepts_balanced_literal_braces() {
let input = "echo ${var#foo{bar}}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
let ParameterOp::RemovePrefixShort { pattern } = operator else {
panic!("expected short-prefix trim operator");
};
assert_eq!(pattern.render(input), "foo{bar}");
}
#[test]
fn test_parameter_expansion_trim_operand_tracks_nested_parameter_expansions() {
let input = "echo ${var#${prefix:-fallback}}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
let ParameterOp::RemovePrefixShort { pattern } = operator else {
panic!("expected short-prefix trim operator");
};
assert_eq!(pattern.render(input), "${prefix:-fallback}");
assert!(matches!(
&pattern.parts[..],
[PatternPartNode {
kind: PatternPart::Word(word),
..
}] if matches!(
&word.parts[..],
[WordPartNode {
kind: WordPart::Parameter(_) | WordPart::ParameterExpansion { .. },
..
}]
)
));
}
#[test]
fn test_parameter_replacement_pattern_stays_source_backed() {
let input = "echo ${var/foo/bar}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
let ParameterOp::ReplaceFirst {
pattern,
replacement,
replacement_word_ast,
} = operator
else {
panic!("expected replace-first operator");
};
assert_eq!(pattern.render(input), "foo");
assert_eq!(pattern.parts.len(), 1);
assert!(matches!(
&pattern.parts[0].kind,
PatternPart::Literal(text) if text.is_source_backed()
));
assert!(replacement.is_source_backed());
assert_eq!(replacement.slice(input), "bar");
assert_eq!(replacement_word_ast.render(input), "bar");
assert_eq!(replacement_word_ast.span.slice(input), "bar");
}
#[test]
fn test_parameter_trim_pattern_preserves_quoted_fragments_around_expansions() {
let input = "echo ${var#\"pre\"$suffix'-'*}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
let ParameterOp::RemovePrefixShort { pattern } = operator else {
panic!("expected short-prefix trim operator");
};
assert!(matches!(
&pattern.parts[..],
[
PatternPartNode {
kind: PatternPart::Word(first),
..
},
PatternPartNode {
kind: PatternPart::Word(second),
..
},
PatternPartNode {
kind: PatternPart::Word(third),
..
},
PatternPartNode {
kind: PatternPart::AnyString,
..
}
] if first.is_fully_quoted()
&& matches!(
&second.parts[..],
[WordPartNode {
kind: WordPart::Variable(name),
..
}] if name.as_str() == "suffix"
)
&& third.is_fully_quoted()
));
}
#[test]
fn test_parameter_replacement_pattern_preserves_mixed_quote_fragments() {
let input = "echo ${var//\"pre\"$suffix'-'/x}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
let ParameterOp::ReplaceAll {
pattern,
replacement,
replacement_word_ast,
} = operator
else {
panic!("expected replace-all operator");
};
assert_eq!(
pattern_part_slices(pattern, input),
vec!["\"pre\"", "$suffix", "'-'"]
);
assert_eq!(replacement.slice(input), "x");
assert_eq!(replacement_word_ast.render(input), "x");
assert!(matches!(
&pattern.parts[..],
[
PatternPartNode {
kind: PatternPart::Word(first),
..
},
PatternPartNode {
kind: PatternPart::Word(second),
..
},
PatternPartNode {
kind: PatternPart::Word(third),
..
}
] if first.is_fully_quoted()
&& matches!(
&second.parts[..],
[WordPartNode {
kind: WordPart::Variable(name),
..
}] if name.as_str() == "suffix"
)
&& third.is_fully_quoted()
));
}
#[test]
fn test_parameter_replacement_pattern_cooks_escaped_slash() {
let input = r#"echo ${var/foo\/bar/baz}"#;
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
let ParameterOp::ReplaceFirst {
pattern,
replacement,
replacement_word_ast,
} = operator
else {
panic!("expected replace-first operator");
};
assert_eq!(pattern.render(input), "foo/bar");
assert_eq!(pattern.parts.len(), 1);
assert!(matches!(
&pattern.parts[0].kind,
PatternPart::Literal(text) if !text.is_source_backed() && text == "foo/bar"
));
assert!(replacement.is_source_backed());
assert_eq!(replacement_word_ast.render(input), "baz");
assert_eq!(replacement.slice(input), "baz");
}
#[test]
fn test_parameter_replacement_pattern_keeps_escaped_dollar_literal() {
let input = r#"echo "${d//\$ORIGIN/$origin}""#;
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted argument");
};
let (_, operator, _) = expect_parameter_operation_part(&parts[0].kind);
let ParameterOp::ReplaceAll { pattern, .. } = operator else {
panic!("expected replace-all operator");
};
assert_eq!(pattern.render(input), "$ORIGIN");
assert!(matches!(
&pattern.parts[..],
[PatternPartNode {
kind: PatternPart::Literal(text),
..
}] if text == "$ORIGIN"
));
}
#[test]
fn test_parameter_replacement_word_keeps_escaped_single_quotes_literal() {
let input = r#"echo ${dest_dir//\'/\'\\\'\'}"#;
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let word = &command.args[0];
let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
let ParameterOp::ReplaceAll {
replacement: _,
replacement_word_ast,
..
} = operator
else {
panic!("expected replace-all operator");
};
assert!(!replacement_word_ast.parts.iter().any(|part| {
matches!(part.kind, WordPart::SingleQuoted { .. })
&& part.span.slice(input).ends_with("\\'")
}));
}
#[test]
fn test_parameter_replacement_spans_cover_complex_pattern_and_replacement_bodies() {
let input = "\
echo ${dest_dir//\\'/\\'\\\\\\'\\'} ${TERMUX_PKG_VERSION_EDITED//${INCORRECT_SYMBOLS:0:1}${INCORRECT_SYMBOLS:1:1}/${INCORRECT_SYMBOLS:0:1}.${INCORRECT_SYMBOLS:1:1}} ${GITHUB_GRAPHQL_QUERIES[$BATCH * $BATCH_SIZE]//\\\\/} ${run_depends/${i}/${dep}}\n\
";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(
command.args[0]
.parts
.iter()
.map(|part| part.span.slice(input))
.collect::<Vec<_>>(),
vec![r#"${dest_dir//\'/\'\\\'\'}"#]
);
let spans = command
.args
.iter()
.map(|word| word.parts[0].span.slice(input))
.collect::<Vec<_>>();
assert_eq!(
spans,
vec![
"${dest_dir//\\'/\\'\\\\\\'\\'}",
"${TERMUX_PKG_VERSION_EDITED//${INCORRECT_SYMBOLS:0:1}${INCORRECT_SYMBOLS:1:1}/${INCORRECT_SYMBOLS:0:1}.${INCORRECT_SYMBOLS:1:1}}",
"${GITHUB_GRAPHQL_QUERIES[$BATCH * $BATCH_SIZE]//\\\\/}",
"${run_depends/${i}/${dep}}",
]
);
let (_, operator, _) = expect_parameter_operation_part(&command.args[1].parts[0].kind);
let ParameterOp::ReplaceAll {
pattern,
replacement,
replacement_word_ast,
} = operator
else {
panic!("expected replace-all operator");
};
assert_eq!(
pattern.render(input),
"${INCORRECT_SYMBOLS:0:1}${INCORRECT_SYMBOLS:1:1}"
);
assert_eq!(
replacement.slice(input),
"${INCORRECT_SYMBOLS:0:1}.${INCORRECT_SYMBOLS:1:1}"
);
assert_eq!(
replacement_word_ast.render(input),
"${INCORRECT_SYMBOLS:0:1}.${INCORRECT_SYMBOLS:1:1}"
);
let (_, operator, _) = expect_parameter_operation_part(&command.args[3].parts[0].kind);
let ParameterOp::ReplaceFirst {
pattern,
replacement,
replacement_word_ast,
} = operator
else {
panic!("expected replace-first operator");
};
assert_eq!(pattern.render(input), "${i}");
assert_eq!(replacement.slice(input), "${dep}");
assert_eq!(replacement_word_ast.render(input), "${dep}");
}
#[test]
fn test_assignment_replacement_expansion_span_keeps_escaped_backslashes() {
let input = "crypt=${crypt//\\\\/\\\\\\\\}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
let ParameterOp::ReplaceAll {
pattern,
replacement,
replacement_word_ast,
} = operator
else {
panic!("expected replace-all operator");
};
assert_eq!(pattern.span.slice(input), "\\\\");
assert_eq!(replacement.slice(input), "\\\\\\\\");
assert_eq!(replacement_word_ast.span.slice(input), "\\\\\\\\");
assert_eq!(
top_level_part_slices(word, input),
vec!["${crypt//\\\\/\\\\\\\\}"]
);
}
#[test]
fn test_read_replacement_pattern_stops_before_unescaped_delimiter() {
let input = "crypt=${crypt//\\\\/\\\\\\\\}\n";
let parser = Parser::new(input);
let offset = input.find("//").expect("expected replacement operator") + 2;
let mut chars = input[offset..].chars().peekable();
let mut cursor = Position::new().advanced_by(&input[..offset]);
let pattern = parser.read_replacement_pattern(&mut chars, &mut cursor, true);
assert_eq!(pattern.slice(input), "\\\\");
assert_eq!(cursor.offset, offset + 2);
}
#[test]
fn test_decode_cooked_word_keeps_variable_after_literal_backslash() {
let cooked = r#"\$HOME"#;
let span = Span::from_positions(Position::new(), Position::new().advanced_by(cooked));
let word = Parser::new("").decode_word_text(cooked, span, span.start, false);
assert_eq!(word.parts.len(), 2);
let WordPart::Literal(text) = &word.parts[0].kind else {
panic!("expected literal backslash prefix");
};
assert_eq!(text.as_str("", word.parts[0].span), "\\");
assert!(matches!(
&word.parts[1].kind,
WordPart::Variable(name) if name.as_str() == "HOME"
));
}
#[test]
fn test_parse_arithmetic_command_with_command_substitution() {
let input = "(($(date -u) > DATE))\n";
let script = Parser::new(input).parse().unwrap().file;
let (compound, redirects) = expect_compound(&script.body[0]);
let AstCompoundCommand::Arithmetic(command) = compound else {
panic!("expected arithmetic compound command");
};
assert!(redirects.is_empty());
assert_eq!(command.left_paren_span.slice(input), "((");
assert_eq!(command.right_paren_span.slice(input), "))");
assert_eq!(command.expr_span.unwrap().slice(input), "$(date -u) > DATE");
let expr = command
.expr_ast
.as_ref()
.expect("expected typed arithmetic AST");
let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
panic!("expected binary arithmetic expression");
};
assert_eq!(*op, ArithmeticBinaryOp::GreaterThan);
expect_shell_word(left, input, "$(date -u)");
expect_variable(right, "DATE");
}
#[test]
fn test_parse_arithmetic_command_distinguishes_assignment_from_comparison() {
let input = "(( a = b == c ))\n";
let script = Parser::new(input).parse().unwrap().file;
let (compound, _) = expect_compound(&script.body[0]);
let AstCompoundCommand::Arithmetic(command) = compound else {
panic!("expected arithmetic compound command");
};
let expr = command
.expr_ast
.as_ref()
.expect("expected typed arithmetic AST");
let ArithmeticExpr::Assignment { target, op, value } = &expr.kind else {
panic!("expected arithmetic assignment");
};
assert_eq!(*op, ArithmeticAssignOp::Assign);
let ArithmeticLvalue::Variable(name) = target else {
panic!("expected variable assignment target");
};
assert_eq!(name, "a");
let ArithmeticExpr::Binary {
left,
op: cmp_op,
right,
} = &value.kind
else {
panic!("expected comparison on assignment right-hand side");
};
assert_eq!(*cmp_op, ArithmeticBinaryOp::Equal);
expect_variable(left, "b");
expect_variable(right, "c");
}
#[test]
fn test_parse_arithmetic_command_accepts_command_substitutions_and_quoted_words() {
let input = "(( \"$(date -u)\" + '3' ))\n";
let script = Parser::new(input).parse().unwrap().file;
let (compound, _) = expect_compound(&script.body[0]);
let AstCompoundCommand::Arithmetic(command) = compound else {
panic!("expected arithmetic compound command");
};
let expr = command
.expr_ast
.as_ref()
.expect("expected typed arithmetic AST");
let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
panic!("expected binary arithmetic expression");
};
assert_eq!(*op, ArithmeticBinaryOp::Add);
let ArithmeticExpr::ShellWord(left_word) = &left.kind else {
panic!("expected quoted shell word on left");
};
assert_eq!(left_word.span.slice(input), "\"$(date -u)\"");
let ArithmeticExpr::ShellWord(right_word) = &right.kind else {
panic!("expected quoted shell word on right");
};
assert_eq!(right_word.span.slice(input), "'3'");
}
#[test]
fn test_parse_zsh_arithmetic_command_keeps_subscripted_shell_words_intact() {
let input = "(( $+aliases[(e)$1] ))\n(( $cmdnames[(Ie)$point] ))\n";
let script = Parser::with_dialect(input, ShellDialect::Zsh)
.parse()
.unwrap()
.file;
let (first, _) = expect_compound(&script.body[0]);
let AstCompoundCommand::Arithmetic(first) = first else {
panic!("expected arithmetic compound command");
};
let first_expr = first.expr_ast.as_ref().expect("expected arithmetic AST");
expect_shell_word(first_expr, input, "$+aliases[(e)$1]");
let (second, _) = expect_compound(&script.body[1]);
let AstCompoundCommand::Arithmetic(second) = second else {
panic!("expected arithmetic compound command");
};
let second_expr = second.expr_ast.as_ref().expect("expected arithmetic AST");
expect_shell_word(second_expr, input, "$cmdnames[(Ie)$point]");
}
#[test]
fn test_parse_zsh_arithmetic_command_supports_char_literal_numbers() {
let input = "(( #c < 256 / $1 * $1 ))\n(( rnd = (~(1 << 23) & rnd) << 8 | #c ))\n";
let script = Parser::with_dialect(input, ShellDialect::Zsh)
.parse()
.unwrap()
.file;
let (first, _) = expect_compound(&script.body[0]);
let AstCompoundCommand::Arithmetic(first) = first else {
panic!("expected arithmetic compound command");
};
let first_expr = first.expr_ast.as_ref().expect("expected arithmetic AST");
let ArithmeticExpr::Binary { left, op, right } = &first_expr.kind else {
panic!("expected binary arithmetic expression");
};
assert_eq!(*op, ArithmeticBinaryOp::LessThan);
expect_number(left, input, "#c");
let ArithmeticExpr::Binary {
left: mul_left,
op: mul_op,
right: mul_right,
} = &right.kind
else {
panic!("expected multiplication on right-hand side");
};
assert_eq!(*mul_op, ArithmeticBinaryOp::Multiply);
expect_shell_word(mul_right, input, "$1");
let ArithmeticExpr::Binary {
left: div_left,
op: div_op,
right: div_right,
} = &mul_left.kind
else {
panic!("expected division on left-hand side");
};
assert_eq!(*div_op, ArithmeticBinaryOp::Divide);
expect_number(div_left, input, "256");
expect_shell_word(div_right, input, "$1");
let (second, _) = expect_compound(&script.body[1]);
let AstCompoundCommand::Arithmetic(second) = second else {
panic!("expected arithmetic compound command");
};
let second_expr = second.expr_ast.as_ref().expect("expected arithmetic AST");
let ArithmeticExpr::Assignment { target, op, value } = &second_expr.kind else {
panic!("expected arithmetic assignment");
};
assert_eq!(*op, ArithmeticAssignOp::Assign);
let ArithmeticLvalue::Variable(name) = target else {
panic!("expected variable assignment target");
};
assert_eq!(name, "rnd");
let ArithmeticExpr::Binary {
left: or_left,
op: or_op,
right: or_right,
} = &value.kind
else {
panic!("expected bitwise or value");
};
assert_eq!(*or_op, ArithmeticBinaryOp::BitwiseOr);
assert!(matches!(or_left.kind, ArithmeticExpr::Binary { .. }));
expect_number(or_right, input, "#c");
}
#[test]
fn test_for_loop_words_consume_segmented_tokens_directly() {
let input = "for item in foo\"bar\" 'baz'qux; do echo \"$item\"; done";
let script = Parser::new(input).parse().unwrap().file;
let (compound, _) = expect_compound(&script.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
let words = command.words.as_ref().expect("expected explicit for words");
assert_eq!(words.len(), 2);
assert_eq!(words[0].render(input), "foobar");
assert_eq!(words[0].parts.len(), 2);
assert_eq!(words[0].part_span(0).unwrap().slice(input), "foo");
assert_eq!(words[0].part_span(1).unwrap().slice(input), "\"bar\"");
assert_eq!(words[1].render(input), "bazqux");
assert!(!is_fully_quoted(&words[1]));
assert_eq!(words[1].parts.len(), 2);
assert_eq!(words[1].part_span(0).unwrap().slice(input), "'baz'");
assert_eq!(words[1].part_span(1).unwrap().slice(input), "qux");
}
#[test]
fn test_parse_conditional_var_ref_operand_preserves_quoted_subscript_syntax() {
let input = "[[ -v assoc[\"key\"] ]]\n";
let script = Parser::new(input).parse().unwrap().file;
let (compound, _) = expect_compound(&script.body[0]);
let AstCompoundCommand::Conditional(command) = compound else {
panic!("expected conditional compound command");
};
let ConditionalExpr::Unary(unary) = &command.expression else {
panic!("expected unary conditional");
};
let ConditionalExpr::VarRef(var_ref) = unary.expr.as_ref() else {
panic!("expected typed var-ref operand");
};
let subscript = expect_subscript_syntax(var_ref, input, "\"key\"", "key");
assert!(matches!(subscript.kind, SubscriptKind::Ordinary));
}
#[test]
fn test_parse_conditional_var_ref_operand_preserves_spaced_zero_subscript() {
let input = "[[ -v assoc[ 0 ] ]]\n";
let script = Parser::new(input).parse().unwrap().file;
let (compound, _) = expect_compound(&script.body[0]);
let AstCompoundCommand::Conditional(command) = compound else {
panic!("expected conditional compound command");
};
let ConditionalExpr::Unary(unary) = &command.expression else {
panic!("expected unary conditional");
};
let ConditionalExpr::VarRef(var_ref) = unary.expr.as_ref() else {
panic!("expected typed var-ref operand");
};
let subscript = expect_subscript(var_ref, input, " 0 ");
assert!(matches!(
subscript.arithmetic_ast.as_ref().map(|expr| &expr.kind),
Some(ArithmeticExpr::Number(_))
));
}
#[test]
fn test_parse_conditional_var_ref_operand_preserves_nested_arithmetic_subscript() {
let input = "[[ -v assoc[$((0))] ]]\n";
let script = Parser::new(input).parse().unwrap().file;
let (compound, _) = expect_compound(&script.body[0]);
let AstCompoundCommand::Conditional(command) = compound else {
panic!("expected conditional compound command");
};
let ConditionalExpr::Unary(unary) = &command.expression else {
panic!("expected unary conditional");
};
let ConditionalExpr::VarRef(var_ref) = unary.expr.as_ref() else {
panic!("expected typed var-ref operand");
};
let subscript = expect_subscript(var_ref, input, "$((0))");
assert!(subscript.arithmetic_ast.is_some());
}
#[test]
fn test_parse_conditional_non_direct_var_ref_falls_back_to_word() {
let input = "[[ -v prefix$var ]]\n";
let script = Parser::new(input).parse().unwrap().file;
let (compound, _) = expect_compound(&script.body[0]);
let AstCompoundCommand::Conditional(command) = compound else {
panic!("expected conditional compound command");
};
let ConditionalExpr::Unary(unary) = &command.expression else {
panic!("expected unary conditional");
};
let ConditionalExpr::Word(word) = unary.expr.as_ref() else {
panic!("expected word fallback");
};
assert_eq!(word.render(input), "prefix$var");
}
#[test]
fn test_parse_pattern_preserves_dynamic_fragments_inside_extglob() {
let input = "[[ value == --@($choice|$prefix-'x') ]]\n";
let script = Parser::new(input).parse().unwrap().file;
let (compound, _) = expect_compound(&script.body[0]);
let AstCompoundCommand::Conditional(command) = compound else {
panic!("expected conditional compound command");
};
let ConditionalExpr::Binary(binary) = &command.expression else {
panic!("expected binary conditional");
};
let ConditionalExpr::Pattern(pattern) = binary.right.as_ref() else {
panic!("expected pattern rhs");
};
assert_eq!(pattern.render(input), "--@($choice|$prefix-x)");
let PatternPart::Group { patterns, .. } = &pattern.parts[1].kind else {
panic!("expected extglob group");
};
assert!(matches!(
&patterns[0].parts[..],
[PatternPartNode {
kind: PatternPart::Word(word),
..
}] if matches!(
&word.parts[..],
[WordPartNode {
kind: WordPart::Variable(name),
..
}]
if name == "choice"
)
));
assert!(matches!(
&patterns[1].parts[..],
[
PatternPartNode {
kind: PatternPart::Word(variable),
..
},
PatternPartNode {
kind: PatternPart::Literal(text),
..
},
PatternPartNode {
kind: PatternPart::Word(quoted),
..
}
] if matches!(
&variable.parts[..],
[WordPartNode {
kind: WordPart::Variable(name),
..
}]
if name == "prefix"
) && text.as_str(input, patterns[1].parts[1].span) == "-" && is_fully_quoted(quoted)
));
}
#[test]
fn test_parse_conditional_regex_rejects_unquoted_right_brace_operand() {
let input = "[[ { =~ { ]]\n";
assert!(Parser::new(input).parse().is_err());
}
#[test]
fn test_parse_glob_word_with_embedded_quote_stays_single_arg() {
let input = "echo [hello\"]\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.args.len(), 1);
assert_eq!(command.args[0].span.slice(input), "[hello\"]\"");
}
#[test]
fn test_parse_glob_word_with_command_sub_in_bracket_expression_stays_single_arg() {
let input = "echo [$(echo abc)]\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.args.len(), 1);
assert_eq!(command.args[0].span.slice(input), "[$(echo abc)]");
}
#[test]
fn test_parse_glob_word_with_extglob_chars_stays_single_arg() {
let input = "echo [+()]\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.args.len(), 1);
assert_eq!(command.args[0].span.slice(input), "[+()]");
}
#[test]
fn test_parse_glob_word_with_trailing_literal_right_paren_stays_single_arg() {
let input = "echo [+(])\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.args.len(), 1);
assert_eq!(command.args[0].span.slice(input), "[+(])");
}
#[test]
fn test_parse_glob_of_unescaped_double_left_bracket_stays_word() {
let input = "echo [[z] []z]\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.args.len(), 2);
assert_eq!(command.args[0].span.slice(input), "[[z]");
assert_eq!(command.args[1].span.slice(input), "[]z]");
}
#[test]
fn test_parse_parameter_expansion_operands_allow_quoted_and_escaped_right_brace() {
let input = r###"echo "${var#\}}"
echo "${var#'}'}"
echo "${var#"}"}"
echo "${var-\}}"
echo "${var-'}'}"
echo "${var-"}"}"
"###;
let script = Parser::new(input).parse().unwrap().file;
assert_eq!(script.body.len(), 6);
}
#[test]
fn test_parse_long_suffix_trim_operator_inside_double_quotes() {
let input = "echo \"${1%%.*}\" \"${package_url%%#*}\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
for word in &command.args {
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted word");
};
let parameter = match &parts[0].kind {
WordPart::Parameter(parameter) => parameter,
_ => panic!("expected parameter expansion"),
};
let BourneParameterExpansion::Operation { operator, .. } =
parameter.bourne().expect("expected Bourne syntax")
else {
panic!("expected parameter operation");
};
assert!(matches!(operator, ParameterOp::RemoveSuffixLong { .. }));
}
}
#[test]
fn test_parse_parameter_slices_preserve_shell_style_offsets() {
let input = "echo \"${arg:$index:1}\" \"${@:1:$package_type_nargs}\" \"${@:$(( $package_type_nargs + 1 ))}\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let WordPart::DoubleQuoted {
parts: first_parts, ..
} = &command.args[0].parts[0].kind
else {
panic!("expected first double-quoted word");
};
let (_, first_offset_ast, first_length_ast) = expect_substring_part(&first_parts[0].kind);
let ArithmeticExpr::ShellWord(first_offset_word) =
&first_offset_ast.as_ref().expect("expected offset AST").kind
else {
panic!("expected shell-word offset");
};
assert_eq!(first_offset_word.span.slice(input), "$index");
assert_eq!(
first_length_ast
.as_ref()
.expect("expected first length AST")
.span
.slice(input),
"1"
);
let WordPart::DoubleQuoted {
parts: second_parts,
..
} = &command.args[1].parts[0].kind
else {
panic!("expected second double-quoted word");
};
let (_, second_offset_ast, second_length_ast) = expect_substring_part(&second_parts[0].kind);
assert_eq!(
second_offset_ast
.as_ref()
.expect("expected second offset AST")
.span
.slice(input),
"1"
);
let ArithmeticExpr::ShellWord(second_length_word) = &second_length_ast
.as_ref()
.expect("expected second length AST")
.kind
else {
panic!("expected shell-word length");
};
assert_eq!(second_length_word.span.slice(input), "$package_type_nargs");
let WordPart::DoubleQuoted {
parts: third_parts, ..
} = &command.args[2].parts[0].kind
else {
panic!("expected third double-quoted word");
};
let (_, third_offset_ast, third_length_ast) = expect_substring_part(&third_parts[0].kind);
assert!(third_length_ast.is_none());
let ArithmeticExpr::ShellWord(third_offset_word) = &third_offset_ast
.as_ref()
.expect("expected third offset AST")
.kind
else {
panic!("expected shell-word arithmetic expansion");
};
assert_eq!(
third_offset_word.span.slice(input),
"$(( $package_type_nargs + 1 ))"
);
}
#[test]
fn test_command_substitution_spans_are_absolute() {
let script = Parser::new("out=$(\n printf '%s\\n' $x\n)\n")
.parse()
.unwrap()
.file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
let WordPart::CommandSubstitution {
body: commands,
syntax,
} = &word.parts[0].kind
else {
panic!("expected command substitution");
};
assert_eq!(*syntax, CommandSubstitutionSyntax::DollarParen);
let inner = expect_simple(&commands[0]);
assert_eq!(inner.name.span.start.line, 2);
assert_eq!(inner.name.span.start.column, 3);
assert_eq!(inner.args[0].span.start.line, 2);
assert_eq!(inner.args[1].span.start.column, 17);
}
#[test]
fn test_prefixed_nested_command_substitution_keeps_command_names() {
let input = "cp -v $filename $OUT/$(echo $(basename $filename .fuzz))\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let echo_body = command.args[2]
.parts
.iter()
.find_map(|part| match &part.kind {
WordPart::CommandSubstitution { body, .. } => Some(body),
_ => None,
})
.expect("expected outer command substitution");
let echo = expect_simple(&echo_body[0]);
assert_eq!(echo.name.render(input), "echo");
let basename_body = echo.args[0]
.parts
.iter()
.find_map(|part| match &part.kind {
WordPart::CommandSubstitution { body, .. } => Some(body),
_ => None,
})
.expect("expected nested basename command substitution");
let basename = expect_simple(&basename_body[0]);
assert_eq!(basename.name.render(input), "basename");
}
#[test]
fn test_prefixed_nested_quoted_command_substitution_keeps_command_names() {
let input = "\
value=\"$(\n\
[[ \"$config_file\" == *\"$theme.cfg\" ]] && echo \"$(basename \"$config_file\")\"\n\
)\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected quoted assignment value");
};
let WordPart::CommandSubstitution { body, .. } = &parts[0].kind else {
panic!("expected outer command substitution");
};
let AstCommand::Binary(binary) = &body[0].command else {
panic!("expected binary command");
};
let echo = expect_simple(&binary.right);
assert_eq!(echo.name.render(input), "echo");
let WordPart::DoubleQuoted {
parts: echo_parts, ..
} = &echo.args[0].parts[0].kind
else {
panic!("expected quoted echo argument");
};
let WordPart::CommandSubstitution {
body: basename_body,
..
} = &echo_parts[0].kind
else {
panic!("expected nested basename substitution");
};
let basename = expect_simple(&basename_body[0]);
assert_eq!(basename.name.render(input), "basename");
}
#[test]
fn test_prefixed_nested_escaped_quoted_command_substitution_keeps_command_names() {
let input = "#!/bin/sh\necho -n \"\\\"adp_$(echo $var | tr A-Z a-z)\\\": [\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let WordPart::DoubleQuoted { parts, .. } = &command.args[1].parts[0].kind else {
panic!("expected quoted echo argument");
};
let body = parts
.iter()
.find_map(|part| match &part.kind {
WordPart::CommandSubstitution { body, .. } => Some(body),
_ => None,
})
.expect("expected nested command substitution");
let AstCommand::Binary(pipeline) = &body[0].command else {
panic!("expected piped command");
};
let echo = expect_simple(&pipeline.left);
assert_eq!(echo.name.render(input), "echo");
let tr_command = expect_simple(&pipeline.right);
assert_eq!(tr_command.name.render(input), "tr");
assert_eq!(tr_command.args[0].render(input), "A-Z");
assert_eq!(tr_command.args[1].render(input), "a-z");
}
#[test]
fn test_prefixed_nested_escaped_quoted_command_substitution_keeps_command_names_after_apostrophe() {
let input = "#!/bin/sh\necho -n \"it's \\\"adp_$(echo $var | tr A-Z a-z)\\\": [\"\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let WordPart::DoubleQuoted { parts, .. } = &command.args[1].parts[0].kind else {
panic!("expected quoted echo argument");
};
let body = parts
.iter()
.find_map(|part| match &part.kind {
WordPart::CommandSubstitution { body, .. } => Some(body),
_ => None,
})
.expect("expected nested command substitution");
let AstCommand::Binary(pipeline) = &body[0].command else {
panic!("expected piped command");
};
let echo = expect_simple(&pipeline.left);
assert_eq!(echo.name.render(input), "echo");
let tr_command = expect_simple(&pipeline.right);
assert_eq!(tr_command.name.render(input), "tr");
assert_eq!(tr_command.args[0].render(input), "A-Z");
assert_eq!(tr_command.args[1].render(input), "a-z");
}
#[test]
fn test_parse_command_substitution_with_open_paren_inside_double_quotes() {
Parser::new("x=$(echo \"(\")\n").parse().unwrap();
}
#[test]
fn test_parse_command_substitution_with_case_pattern_right_paren() {
let input = "echo $(foo=a; case $foo in [0-9]) echo number;; [a-z]) echo letter ;; esac)\n";
Parser::new(input).parse().unwrap();
}
#[test]
fn test_process_substitution_spans_are_absolute() {
let script = Parser::new("cat <(\n printf '%s\\n' $x\n)\n")
.parse()
.unwrap()
.file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let WordPart::ProcessSubstitution {
body: commands,
is_input,
} = &command.args[0].parts[0].kind
else {
panic!("expected process substitution");
};
assert!(*is_input);
let inner = expect_simple(&commands[0]);
assert_eq!(inner.name.span.start.line, 2);
assert_eq!(inner.name.span.start.column, 3);
assert_eq!(inner.args[1].span.start.column, 17);
}
#[test]
fn test_parse_declare_clause_classifies_operands_and_prefix_assignments() {
let input = "FOO=1 declare -a arr=(\"hello world\" two) bar >out\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Decl(command) = &script.body[0].command else {
panic!("expected declaration clause");
};
assert_eq!(command.variant, "declare");
assert_eq!(command.variant_span.slice(input), "declare");
assert_eq!(command.assignments.len(), 1);
assert_eq!(command.assignments[0].target.name, "FOO");
assert_eq!(script.body[0].redirects.len(), 1);
assert_eq!(
redirect_word_target(&script.body[0].redirects[0])
.span
.slice(input),
"out"
);
assert_eq!(command.operands.len(), 3);
let DeclOperand::Flag(flag) = &command.operands[0] else {
panic!("expected flag operand");
};
assert_eq!(flag.span.slice(input), "-a");
let DeclOperand::Assignment(assignment) = &command.operands[1] else {
panic!("expected assignment operand");
};
assert_eq!(assignment.target.name, "arr");
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.kind, ArrayKind::Indexed);
assert_eq!(array.elements.len(), 2);
let ArrayElem::Sequential(first) = &array.elements[0] else {
panic!("expected first sequential element");
};
assert!(is_fully_quoted(first));
assert_eq!(first.span.slice(input), "\"hello world\"");
let ArrayElem::Sequential(second) = &array.elements[1] else {
panic!("expected second sequential element");
};
assert_eq!(second.span.slice(input), "two");
let DeclOperand::Name(name) = &command.operands[2] else {
panic!("expected bare name operand");
};
assert_eq!(name.name, "bar");
}
#[test]
fn test_parse_declare_a_threads_associative_kind_into_compound_array() {
let input = "declare -A assoc=(one [foo]=bar [bar]+=baz two)\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Decl(command) = &script.body[0].command else {
panic!("expected declaration clause");
};
let DeclOperand::Assignment(assignment) = &command.operands[1] else {
panic!("expected assignment operand, got {:#?}", command.operands);
};
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.kind, ArrayKind::Associative);
assert_eq!(array.elements.len(), 4);
assert!(matches!(array.elements[0], ArrayElem::Sequential(_)));
let ArrayElem::Keyed { key, .. } = &array.elements[1] else {
panic!("expected keyed element");
};
assert_eq!(key.text.slice(input), "foo");
assert_eq!(key.interpretation, SubscriptInterpretation::Associative);
let ArrayElem::KeyedAppend { key, .. } = &array.elements[2] else {
panic!("expected keyed append element");
};
assert_eq!(key.text.slice(input), "bar");
assert_eq!(key.interpretation, SubscriptInterpretation::Associative);
assert!(matches!(array.elements[3], ArrayElem::Sequential(_)));
}
#[test]
fn test_parse_declare_array_preserves_quoted_command_substitution_elements() {
let input = "f() {\n\tlocal -a graphql_request=(\n\t\t-X POST\n\t\t-d \"$(\n\t\t\tcat <<-EOF | tr '\\n' ' '\n\t\t\t\t{\"query\":\"field, direction\"}\n\t\t\tEOF\n\t\t)\"\n\t)\n}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Function(function) = &script.body[0].command else {
panic!("expected function");
};
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::BraceGroup(body) = compound else {
panic!("expected brace-group function body");
};
assert!(redirects.is_empty());
let AstCommand::Decl(command) = &body[0].command else {
panic!("expected declaration, got {:#?}", body[0].command);
};
let DeclOperand::Assignment(assignment) = &command.operands[1] else {
panic!("expected assignment operand, got {:#?}", command.operands);
};
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.elements.len(), 4, "{:#?}", array.elements);
let ArrayElem::Sequential(payload) = &array.elements[3] else {
panic!("expected payload element");
};
assert!(
payload.parts.iter().any(|part| {
matches!(
&part.kind,
WordPart::DoubleQuoted { parts, .. }
if parts.iter().any(|part| matches!(
&part.kind,
WordPart::CommandSubstitution {
syntax: CommandSubstitutionSyntax::DollarParen,
..
}
))
)
}),
"{:#?}",
payload.parts
);
}
#[test]
fn test_parse_array_append_preserves_pipeline_command_substitution_span() {
let input = "CANDIDATES+=(\"$(echo \"$line\" | cut -d' ' -f2-)\")\n";
let script = Parser::new(input).parse().unwrap().file;
let assignment = match &script.body[0].command {
AstCommand::Simple(command) => &command.assignments[0],
AstCommand::Decl(command) => match &command.operands[0] {
DeclOperand::Assignment(assignment) => assignment,
operand => panic!("expected assignment operand, got {operand:#?}"),
},
command => panic!("expected assignment command, got {command:#?}"),
};
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
let ArrayElem::Sequential(word) = &array.elements[0] else {
panic!("expected sequential array element");
};
let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
panic!("expected double-quoted array element");
};
let WordPart::CommandSubstitution { body, .. } = &parts[0].kind else {
panic!("expected command substitution");
};
assert_eq!(
parts[0].span.slice(input),
"$(echo \"$line\" | cut -d' ' -f2-)"
);
assert!(matches!(&body[0].command, AstCommand::Binary(_)));
}
#[test]
fn test_parse_declare_array_preserves_separator_comment_in_quoted_command_substitution() {
let input = "f() {\n\tlocal -a parts=(\n\t\t\"$(printf '%s' x;# comment with ) and ,\n\t\tprintf '%s' y\n\t\t)\"\n\t)\n}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Function(function) = &script.body[0].command else {
panic!("expected function");
};
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::BraceGroup(body) = compound else {
panic!("expected brace-group function body");
};
assert!(redirects.is_empty());
let AstCommand::Decl(command) = &body[0].command else {
panic!("expected declaration, got {:#?}", body[0].command);
};
let DeclOperand::Assignment(assignment) = &command.operands[1] else {
panic!("expected assignment operand, got {:#?}", command.operands);
};
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.elements.len(), 1, "{:#?}", array.elements);
let ArrayElem::Sequential(payload) = &array.elements[0] else {
panic!("expected payload element");
};
assert!(
payload.parts.iter().any(|part| {
matches!(
&part.kind,
WordPart::DoubleQuoted { parts, .. }
if parts.iter().any(|part| matches!(
&part.kind,
WordPart::CommandSubstitution {
syntax: CommandSubstitutionSyntax::DollarParen,
..
}
))
)
}),
"{:#?}",
payload.parts
);
}
#[test]
fn test_parse_declare_array_preserves_piped_heredoc_without_spacing_in_command_substitution() {
let input = "f() {\n\tlocal -a graphql_request=(\n\t\t\"$(\ncat <<EOF|tr '\\n' ' '\n{\"query\":\"field, direction\"}\nEOF\n\t\t)\"\n\t)\n}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Function(function) = &script.body[0].command else {
panic!("expected function");
};
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::BraceGroup(body) = compound else {
panic!("expected brace-group function body");
};
assert!(redirects.is_empty());
let AstCommand::Decl(command) = &body[0].command else {
panic!("expected declaration, got {:#?}", body[0].command);
};
let DeclOperand::Assignment(assignment) = &command.operands[1] else {
panic!("expected assignment operand, got {:#?}", command.operands);
};
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.elements.len(), 1, "{:#?}", array.elements);
let ArrayElem::Sequential(payload) = &array.elements[0] else {
panic!("expected payload element");
};
assert!(
payload.parts.iter().any(|part| {
matches!(
&part.kind,
WordPart::DoubleQuoted { parts, .. }
if parts.iter().any(|part| matches!(
&part.kind,
WordPart::CommandSubstitution {
syntax: CommandSubstitutionSyntax::DollarParen,
..
}
))
)
}),
"{:#?}",
payload.parts
);
}
#[test]
fn test_parse_declare_array_preserves_parameter_expansion_with_right_paren_in_command_substitution()
{
let input = "f() {\n\tlocal -a parts=(\n\t\t\"$(printf %s ${x//foo/)},1)\"\n\t)\n}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Function(function) = &script.body[0].command else {
panic!("expected function");
};
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::BraceGroup(body) = compound else {
panic!("expected brace-group function body");
};
assert!(redirects.is_empty());
let AstCommand::Decl(command) = &body[0].command else {
panic!("expected declaration, got {:#?}", body[0].command);
};
let DeclOperand::Assignment(assignment) = &command.operands[1] else {
panic!("expected assignment operand, got {:#?}", command.operands);
};
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.elements.len(), 1, "{:#?}", array.elements);
let ArrayElem::Sequential(payload) = &array.elements[0] else {
panic!("expected payload element");
};
assert!(
payload.parts.iter().any(|part| {
matches!(
&part.kind,
WordPart::DoubleQuoted { parts, .. }
if parts.iter().any(|part| matches!(
&part.kind,
WordPart::CommandSubstitution {
syntax: CommandSubstitutionSyntax::DollarParen,
..
}
))
)
}),
"{:#?}",
payload.parts
);
}
#[test]
fn test_parse_declare_array_preserves_plain_case_words_in_command_substitution() {
let input = "f() {\n\tlocal -a parts=(\n\t\t$(printf %s 1,2; echo case in)\n\t)\n}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Function(function) = &script.body[0].command else {
panic!("expected function");
};
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::BraceGroup(body) = compound else {
panic!("expected brace-group function body");
};
assert!(redirects.is_empty());
let AstCommand::Decl(command) = &body[0].command else {
panic!("expected declaration, got {:#?}", body[0].command);
};
let DeclOperand::Assignment(assignment) = &command.operands[1] else {
panic!("expected assignment operand, got {:#?}", command.operands);
};
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.elements.len(), 1, "{:#?}", array.elements);
let ArrayElem::Sequential(payload) = &array.elements[0] else {
panic!("expected payload element");
};
assert!(
payload.parts.iter().any(|part| matches!(
&part.kind,
WordPart::CommandSubstitution {
syntax: CommandSubstitutionSyntax::DollarParen,
..
}
)),
"{:#?}",
payload.parts
);
}
#[test]
fn test_parse_declare_array_preserves_ansi_c_quotes_in_command_substitution() {
let input = "f() {\n\tlocal -a parts=(\n\t\t$(printf %s $'a\\'b'; printf %s 1,2)\n\t)\n}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Function(function) = &script.body[0].command else {
panic!("expected function");
};
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::BraceGroup(body) = compound else {
panic!("expected brace-group function body");
};
assert!(redirects.is_empty());
let AstCommand::Decl(command) = &body[0].command else {
panic!("expected declaration, got {:#?}", body[0].command);
};
let DeclOperand::Assignment(assignment) = &command.operands[1] else {
panic!("expected assignment operand, got {:#?}", command.operands);
};
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.elements.len(), 1, "{:#?}", array.elements);
let ArrayElem::Sequential(payload) = &array.elements[0] else {
panic!("expected payload element");
};
assert!(
payload.parts.iter().any(|part| matches!(
&part.kind,
WordPart::CommandSubstitution {
syntax: CommandSubstitutionSyntax::DollarParen,
..
}
)),
"{:#?}",
payload.parts
);
}
#[test]
fn test_parse_declare_array_preserves_backticks_with_right_parens_in_command_substitution() {
let input = "f() {\n\tlocal -a parts=(\n\t\t$(printf %s `echo foo)`; printf %s ok)\n\t)\n}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Function(function) = &script.body[0].command else {
panic!("expected function");
};
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::BraceGroup(body) = compound else {
panic!("expected brace-group function body");
};
assert!(redirects.is_empty());
let AstCommand::Decl(command) = &body[0].command else {
panic!("expected declaration, got {:#?}", body[0].command);
};
let DeclOperand::Assignment(assignment) = &command.operands[1] else {
panic!("expected assignment operand, got {:#?}", command.operands);
};
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.elements.len(), 1, "{:#?}", array.elements);
let ArrayElem::Sequential(payload) = &array.elements[0] else {
panic!("expected payload element");
};
assert!(
payload.parts.iter().any(|part| matches!(
&part.kind,
WordPart::CommandSubstitution {
syntax: CommandSubstitutionSyntax::DollarParen,
..
}
)),
"{:#?}",
payload.parts
);
}
#[test]
fn test_parse_declare_array_preserves_backticks_inside_parameter_expansions_in_command_substitution()
{
let input = "f() {\n\tlocal -a parts=(\n\t\t\"$(printf %s ${x/`echo }`/foo)},1)\"\n\t)\n}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Function(function) = &script.body[0].command else {
panic!("expected function");
};
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::BraceGroup(body) = compound else {
panic!("expected brace-group function body");
};
assert!(redirects.is_empty());
let AstCommand::Decl(command) = &body[0].command else {
panic!("expected declaration, got {:#?}", body[0].command);
};
let DeclOperand::Assignment(assignment) = &command.operands[1] else {
panic!("expected assignment operand, got {:#?}", command.operands);
};
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.elements.len(), 1, "{:#?}", array.elements);
let ArrayElem::Sequential(payload) = &array.elements[0] else {
panic!("expected payload element");
};
assert!(
payload.parts.iter().any(|part| {
matches!(
&part.kind,
WordPart::DoubleQuoted { parts, .. }
if parts.iter().any(|part| matches!(
&part.kind,
WordPart::CommandSubstitution {
syntax: CommandSubstitutionSyntax::DollarParen,
..
}
))
)
}),
"{:#?}",
payload.parts
);
}
#[test]
fn test_parse_declare_array_preserves_process_substitutions_inside_parameter_expansions_in_command_substitution()
{
let input = "f() {\n\tlocal -a parts=(\n\t\t\"$(printf %s ${x/<(echo })/foo)},1)\"\n\t)\n}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Function(function) = &script.body[0].command else {
panic!("expected function");
};
let (compound, redirects) = expect_compound(function.body.as_ref());
let AstCompoundCommand::BraceGroup(body) = compound else {
panic!("expected brace-group function body");
};
assert!(redirects.is_empty());
let AstCommand::Decl(command) = &body[0].command else {
panic!("expected declaration, got {:#?}", body[0].command);
};
let DeclOperand::Assignment(assignment) = &command.operands[1] else {
panic!("expected assignment operand, got {:#?}", command.operands);
};
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.elements.len(), 1, "{:#?}", array.elements);
let ArrayElem::Sequential(payload) = &array.elements[0] else {
panic!("expected payload element");
};
assert!(
payload.parts.iter().any(|part| {
matches!(
&part.kind,
WordPart::DoubleQuoted { parts, .. }
if parts.iter().any(|part| matches!(
&part.kind,
WordPart::CommandSubstitution {
syntax: CommandSubstitutionSyntax::DollarParen,
..
}
))
)
}),
"{:#?}",
payload.parts
);
}
#[test]
fn test_parse_parameter_expansion_preserves_quoted_associative_subscripts() {
let input = "printf '%s\\n' ${assoc[\"key\"]} ${assoc['k']}\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Simple(command) = &script.body[0].command else {
panic!("expected simple command");
};
let first = expect_array_access(&command.args[1]);
let second = expect_array_access(&command.args[2]);
let first_subscript = expect_subscript_syntax(first, input, "\"key\"", "key");
assert!(matches!(first_subscript.kind, SubscriptKind::Ordinary));
assert_eq!(
first_subscript
.word_ast()
.expect("expected subscript word AST")
.span
.slice(input),
"\"key\""
);
assert_eq!(command.args[1].render_syntax(input), "${assoc[\"key\"]}");
let second_subscript = expect_subscript_syntax(second, input, "'k'", "k");
assert!(matches!(second_subscript.kind, SubscriptKind::Ordinary));
assert_eq!(
second_subscript
.word_ast()
.expect("expected subscript word AST")
.render_syntax(input),
"'k'"
);
assert_eq!(command.args[2].render_syntax(input), "${assoc['k']}");
}
#[test]
fn test_parse_declare_a_preserves_quoted_associative_keys() {
let input = "declare -A assoc=([\"key\"]=bar ['alt']+=baz)\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Decl(command) = &script.body[0].command else {
panic!("expected declaration clause");
};
let DeclOperand::Assignment(assignment) = &command.operands[1] else {
panic!("expected assignment operand");
};
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
let ArrayElem::Keyed { key, .. } = &array.elements[0] else {
panic!("expected keyed element");
};
assert_eq!(key.text.slice(input), "key");
assert_eq!(key.syntax_text(input), "\"key\"");
let ArrayElem::KeyedAppend { key, .. } = &array.elements[1] else {
panic!("expected keyed append element");
};
assert_eq!(key.text.slice(input), "alt");
assert_eq!(key.syntax_text(input), "'alt'");
}
#[test]
fn test_parse_export_uses_dynamic_operand_for_invalid_assignment() {
let script = Parser::new("export foo-bar=(one two)\n")
.parse()
.unwrap()
.file;
let AstCommand::Decl(command) = &script.body[0].command else {
panic!("expected declaration clause");
};
assert_eq!(command.variant, "export");
assert_eq!(command.operands.len(), 1);
let DeclOperand::Dynamic(word) = &command.operands[0] else {
panic!("expected dynamic operand");
};
assert_eq!(
word.span.slice("export foo-bar=(one two)\n"),
"foo-bar=(one two)"
);
}
#[test]
fn test_parse_typeset_clause_classifies_flags_and_assignments() {
let input = "typeset -xr VAR=value other\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Decl(command) = &script.body[0].command else {
panic!("expected declaration clause");
};
assert_eq!(command.variant, "typeset");
assert_eq!(command.variant_span.slice(input), "typeset");
assert_eq!(command.operands.len(), 3);
let DeclOperand::Flag(flag) = &command.operands[0] else {
panic!("expected flag operand");
};
assert_eq!(flag.span.slice(input), "-xr");
let DeclOperand::Assignment(assignment) = &command.operands[1] else {
panic!("expected assignment operand");
};
assert_eq!(assignment.target.name, "VAR");
assert!(
matches!(&assignment.value, AssignmentValue::Scalar(value) if value.span.slice(input) == "value")
);
let DeclOperand::Name(name) = &command.operands[2] else {
panic!("expected bare name operand");
};
assert_eq!(name.name, "other");
}
#[test]
fn test_parse_declaration_name_operand_preserves_nested_arithmetic_subscript() {
let input = "declare assoc[$((0))]\n";
let script = Parser::new(input).parse().unwrap().file;
let AstCommand::Decl(command) = &script.body[0].command else {
panic!("expected declaration clause");
};
let DeclOperand::Name(name) = &command.operands[0] else {
panic!("expected declaration name operand");
};
let subscript = expect_subscript(name, input, "$((0))");
assert!(subscript.arithmetic_ast.is_some());
}
#[test]
fn test_alias_expansion_can_form_a_for_loop_header() {
let input = "\
shopt -s expand_aliases
alias FOR1='for '
alias FOR2='FOR1 '
alias eye1='i '
alias eye2='eye1 '
alias IN='in '
alias onetwo='1 2 '
FOR2 eye2 IN onetwo 3; do echo $i; done
";
let script = Parser::new(input).parse().unwrap().file;
let Some(command) = script.body.last() else {
panic!("expected final command to be a for loop");
};
let (compound, _) = expect_compound(command);
let AstCompoundCommand::For(command) = compound else {
panic!("expected final command to be a for loop");
};
assert_eq!(command.targets[0].name.as_deref(), Some("i"));
assert_eq!(command.words.as_ref().map(|words| words.len()), Some(3));
}
#[test]
fn test_alias_expansion_can_open_a_brace_group() {
let input = "\
shopt -s expand_aliases
alias LEFT='{'
LEFT echo one; echo two; }
";
let script = Parser::new(input).parse().unwrap().file;
let Some(command) = script.body.last() else {
panic!("expected final command to be a brace group");
};
let (compound, _) = expect_compound(command);
let AstCompoundCommand::BraceGroup(commands) = compound else {
panic!("expected final command to be a brace group");
};
assert_eq!(commands.len(), 2);
assert!(matches!(commands[0].command, AstCommand::Simple(_)));
assert!(matches!(commands[1].command, AstCommand::Simple(_)));
}
#[test]
fn test_alias_expansion_can_open_a_subshell() {
let input = "\
shopt -s expand_aliases
alias LEFT='('
LEFT echo one; echo two )
";
let script = Parser::new(input).parse().unwrap().file;
let Some(command) = script.body.last() else {
panic!("expected final command to be a subshell");
};
let (compound, _) = expect_compound(command);
let AstCompoundCommand::Subshell(commands) = compound else {
panic!("expected final command to be a subshell");
};
assert_eq!(commands.len(), 2);
assert!(matches!(commands[0].command, AstCommand::Simple(_)));
assert!(matches!(commands[1].command, AstCommand::Simple(_)));
}
#[test]
fn test_alias_expansion_with_trailing_space_expands_next_word() {
let input = "\
shopt -s expand_aliases
alias greet='echo '
alias subject='hello'
greet subject
";
let script = Parser::new(input).parse().unwrap().file;
let Some(stmt) = script.body.last() else {
panic!("expected final command to be a simple command");
};
let command = expect_simple(stmt);
assert_eq!(command.name.render(input), "echo");
assert_eq!(command.args.len(), 1);
assert_eq!(command.args[0].render(input), "hello");
let WordPart::Literal(name_text) = &command.name.parts[0].kind else {
panic!("expected alias-expanded command name to stay literal");
};
let WordPart::Literal(arg_text) = &command.args[0].parts[0].kind else {
panic!("expected alias-expanded arg to stay literal");
};
assert!(!name_text.is_source_backed());
assert!(!arg_text.is_source_backed());
}
#[test]
fn test_alias_expansion_with_trailing_space_waits_until_replay_finishes() {
let input = "\
shopt -s expand_aliases
alias e_='for i in 1 2 3; do echo '
e_ $i; done
";
let script = Parser::new(input).parse().unwrap().file;
let Some(stmt) = script.body.last() else {
panic!("expected final command to be a for loop");
};
let (compound, _) = expect_compound(stmt);
let AstCompoundCommand::For(command) = compound else {
panic!("expected final command to be a for loop");
};
assert_eq!(command.targets[0].name.as_deref(), Some("i"));
assert_eq!(command.words.as_ref().map(|words| words.len()), Some(3));
let Some(body_stmt) = command.body.first() else {
panic!("expected loop body command");
};
let body_command = expect_simple(body_stmt);
assert_eq!(body_command.name.render(input), "echo");
assert_eq!(body_command.args.len(), 1);
assert_eq!(body_command.args[0].render(input), "$i");
}
#[test]
fn test_alias_expansion_recursive_guard_stops_self_reference() {
let input = "\
shopt -s expand_aliases
alias loop='loop echo'
loop
";
let script = Parser::new(input).parse().unwrap().file;
let Some(stmt) = script.body.last() else {
panic!("expected final command to be a simple command");
};
let command = expect_simple(stmt);
assert_eq!(command.name.render(input), "loop");
assert_eq!(command.args.len(), 1);
assert_eq!(command.args[0].render(input), "echo");
}
#[test]
fn test_comment_ranges_simple() {
let source = "# head\necho hi # inline\n# tail\n";
let output = Parser::new(source).parse().unwrap();
assert_eq!(collect_file_comments(&output.file).len(), 3);
assert_comment_ranges_valid(source, &output);
}
#[test]
fn test_comment_ranges_with_unicode() {
let source = "# café résumé\necho ok\n# 你好世界\n";
let output = Parser::new(source).parse().unwrap();
assert_eq!(collect_file_comments(&output.file).len(), 2);
assert_comment_ranges_valid(source, &output);
}
#[test]
fn test_if_condition_semicolon_probe_does_not_duplicate_comments() {
let source = "\
if foo; # keep this once
bar; then
baz
fi
";
let output = Parser::new(source).parse().unwrap();
let comments = collect_file_comments(&output.file);
let texts: Vec<&str> = comments
.iter()
.map(|comment| {
let start = usize::from(comment.range.start());
let end = usize::from(comment.range.end());
&source[start..end]
})
.collect();
assert_eq!(texts, vec!["# keep this once"]);
let (compound, _) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::If(command) = compound else {
panic!("expected if command");
};
assert_eq!(command.condition.len(), 2);
}
#[test]
fn test_zsh_dialect_accepts_c_style_for_loops() {
Parser::with_dialect(
"for ((i=0; i<2; i++)); do echo hi; done\n",
ShellDialect::Zsh,
)
.parse()
.unwrap();
}
#[test]
fn test_for_loop_preserves_single_target_and_in_do_done_syntax() {
let source = "for item in a b; do echo \"$item\"; done\n";
let output = Parser::new(source).parse().unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
assert!(redirects.is_empty());
assert_eq!(
command
.targets
.iter()
.map(|target| target.name.as_deref().expect("expected normalized target"))
.collect::<Vec<_>>(),
vec!["item"]
);
assert_eq!(command.targets[0].word.render(source), "item");
assert_eq!(command.targets[0].span.slice(source), "item");
assert_eq!(
command
.words
.as_ref()
.expect("expected explicit word list")
.iter()
.map(|word| word.span.slice(source))
.collect::<Vec<_>>(),
vec!["a", "b"]
);
match command.syntax {
ForSyntax::InDoDone {
in_span: Some(in_span),
do_span,
done_span,
} => {
assert_eq!(in_span.slice(source), "in");
assert_eq!(do_span.slice(source), "do");
assert_eq!(done_span.slice(source), "done");
}
other => panic!("expected in/do/done syntax, got {other:?}"),
}
}
#[test]
fn test_for_loop_preserves_non_identifier_target_surface() {
for source in [
"for - in a b c; do echo hi; done\n",
"for i.j in a b c; do echo hi; done\n",
] {
let output = Parser::new(source).parse().unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
assert!(redirects.is_empty());
assert_eq!(command.targets.len(), 1);
assert_eq!(
command.targets[0].span.slice(source),
command.targets[0].word.render(source)
);
assert!(command.targets[0].name.is_none());
}
}
#[test]
fn test_zsh_for_loop_preserves_multiple_targets() {
let source = "for k v in a b c d; do echo \"$k:$v\"; done\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
assert!(redirects.is_empty());
assert_eq!(
command
.targets
.iter()
.map(|target| target.name.as_deref().expect("expected normalized target"))
.collect::<Vec<_>>(),
vec!["k", "v"]
);
assert_eq!(
command
.words
.as_ref()
.expect("expected explicit word list")
.iter()
.map(|word| word.span.slice(source))
.collect::<Vec<_>>(),
vec!["a", "b", "c", "d"]
);
assert!(matches!(
command.syntax,
ForSyntax::InDoDone {
in_span: Some(_),
..
}
));
}
#[test]
fn test_zsh_for_loop_preserves_digit_targets() {
let source = "for 1 2 3; do echo hi; done\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
assert!(redirects.is_empty());
assert_eq!(
command
.targets
.iter()
.map(|target| target.name.as_deref().expect("expected normalized target"))
.collect::<Vec<_>>(),
vec!["1", "2", "3"]
);
assert!(command.words.is_none());
assert!(matches!(
command.syntax,
ForSyntax::InDoDone { in_span: None, .. }
));
}
#[test]
fn test_zsh_for_loop_preserves_paren_do_done_syntax() {
let source = "for version ($versions); do echo $version; done\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
assert!(redirects.is_empty());
assert_eq!(
command
.targets
.iter()
.map(|target| target.name.as_deref().expect("expected normalized target"))
.collect::<Vec<_>>(),
vec!["version"]
);
assert_eq!(
command
.words
.as_ref()
.expect("expected parenthesized word list")
.iter()
.map(|word| word.span.slice(source))
.collect::<Vec<_>>(),
vec!["$versions"]
);
match command.syntax {
ForSyntax::ParenDoDone {
left_paren_span,
right_paren_span,
do_span,
done_span,
} => {
assert_eq!(left_paren_span.slice(source), "(");
assert_eq!(right_paren_span.slice(source), ")");
assert_eq!(do_span.slice(source), "do");
assert_eq!(done_span.slice(source), "done");
}
other => panic!("expected paren/do/done syntax, got {other:?}"),
}
}
#[test]
fn test_zsh_for_loop_paren_word_list_allows_newlines() {
let source = "for file (\n one\n two\n); do echo $file; done\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
assert!(redirects.is_empty());
assert_eq!(
command
.words
.as_ref()
.expect("expected parenthesized word list")
.iter()
.map(|word| word.span.slice(source))
.collect::<Vec<_>>(),
vec!["one", "two"]
);
assert!(matches!(command.syntax, ForSyntax::ParenDoDone { .. }));
}
#[test]
fn test_zsh_for_loop_paren_word_list_ignores_comments() {
let source =
"for file (\n # first path\n one\n # second path\n two\n); do echo $file; done\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
assert!(redirects.is_empty());
assert_eq!(
command
.words
.as_ref()
.expect("expected parenthesized word list")
.iter()
.map(|word| word.span.slice(source))
.collect::<Vec<_>>(),
vec!["one", "two"]
);
assert!(matches!(command.syntax, ForSyntax::ParenDoDone { .. }));
}
#[test]
fn test_zsh_for_loop_preserves_multi_target_paren_do_done_syntax() {
let source = "for old_name new_name (\n current_branch git_current_branch\n); do aliases[$old_name]=$new_name; done\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
assert!(redirects.is_empty());
assert_eq!(
command
.targets
.iter()
.map(|target| target.name.as_deref().expect("expected normalized target"))
.collect::<Vec<_>>(),
vec!["old_name", "new_name"]
);
assert_eq!(
command
.words
.as_ref()
.expect("expected parenthesized word list")
.iter()
.map(|word| word.span.slice(source))
.collect::<Vec<_>>(),
vec!["current_branch", "git_current_branch"]
);
assert!(matches!(command.syntax, ForSyntax::ParenDoDone { .. }));
}
#[test]
fn test_zsh_for_loop_preserves_paren_brace_syntax() {
let source = "for version ($versions); { echo $version; }\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
assert!(redirects.is_empty());
match command.syntax {
ForSyntax::ParenBrace {
left_paren_span,
right_paren_span,
left_brace_span,
right_brace_span,
} => {
assert_eq!(left_paren_span.slice(source), "(");
assert_eq!(right_paren_span.slice(source), ")");
assert_eq!(left_brace_span.slice(source), "{");
assert_eq!(right_brace_span.slice(source), "}");
}
other => panic!("expected paren/brace syntax, got {other:?}"),
}
}
#[test]
fn test_zsh_for_loop_preserves_paren_direct_syntax() {
let source = "for topic_folder ($ZSH/*) if [ -d $topic_folder ]; then fpath=($topic_folder $fpath); fi\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
assert!(redirects.is_empty());
assert!(matches!(
command.syntax,
ForSyntax::ParenDirect {
left_paren_span,
right_paren_span,
} if left_paren_span.slice(source) == "(" && right_paren_span.slice(source) == ")"
));
assert_eq!(command.body.len(), 1);
assert!(matches!(command.body[0].command, AstCommand::Compound(_)));
}
#[test]
fn test_zsh_for_loop_preserves_paren_glob_qualifier_word_list() {
let source =
"for ind_file ( ${^${(von)PUAssocArray}}.ind(DN.) ) {\n command cat ${ind_file:r}\n}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
assert!(redirects.is_empty());
assert_eq!(
command
.words
.as_ref()
.expect("expected parenthesized word list")
.iter()
.map(|word| word.span.slice(source))
.collect::<Vec<_>>(),
vec!["${^${(von)PUAssocArray}}.ind(DN.)"]
);
assert!(matches!(command.syntax, ForSyntax::ParenBrace { .. }));
}
#[test]
fn test_zsh_for_loop_preserves_in_brace_syntax() {
let source = "for part in a b; { echo $part; }\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
assert!(redirects.is_empty());
match command.syntax {
ForSyntax::InBrace {
in_span: Some(in_span),
left_brace_span,
right_brace_span,
} => {
assert_eq!(in_span.slice(source), "in");
assert_eq!(left_brace_span.slice(source), "{");
assert_eq!(right_brace_span.slice(source), "}");
}
other => panic!("expected in/brace syntax, got {other:?}"),
}
}
#[test]
fn test_zsh_for_loop_preserves_in_direct_syntax() {
let source = "for key in \"$key_info[Escape]\"{B,b} \"$key_info[Left]\"\n bindkey -M emacs \"$key\" emacs-backward-word\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
assert!(redirects.is_empty());
assert!(matches!(
command.syntax,
ForSyntax::InDirect {
in_span: Some(in_span),
} if in_span.slice(source) == "in"
));
assert_eq!(command.body.len(), 1);
assert_eq!(
expect_simple(&command.body[0]).name.render(source),
"bindkey"
);
}
#[test]
fn test_zsh_for_loop_allows_in_as_first_target_name() {
let source = "for in other in a b; do echo \"$in:$other\"; done\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::For(command) = compound else {
panic!("expected for loop");
};
assert!(redirects.is_empty());
assert_eq!(
command
.targets
.iter()
.map(|target| target.name.as_deref().expect("expected normalized target"))
.collect::<Vec<_>>(),
vec!["in", "other"]
);
assert_eq!(
command
.words
.as_ref()
.expect("expected explicit word list")
.iter()
.map(|word| word.span.slice(source))
.collect::<Vec<_>>(),
vec!["a", "b"]
);
}
#[test]
fn test_non_zsh_dialects_reject_zsh_for_loop_forms() {
for source in [
"for k v in a b c; do echo hi; done\n",
"for 1 2 3; do echo hi; done\n",
"for version ($versions); do echo $version; done\n",
"for part in a b; { echo $part; }\n",
] {
for dialect in [ShellDialect::Bash, ShellDialect::Posix, ShellDialect::Mksh] {
let error = Parser::with_dialect(source, dialect).parse();
assert!(
error.is_err(),
"expected parse error for {dialect:?} on {source:?}",
);
}
}
}
#[test]
fn test_zsh_trailing_glob_qualifier_parses_star_dot() {
let source = "print *(.)\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
let glob = expect_zsh_qualified_glob(&command.args[0]);
let qualifiers = expect_zsh_glob_qualifiers(glob);
let [segment] = glob.segments.as_slice() else {
panic!("expected a single pattern segment");
};
assert_eq!(glob.span.slice(source), "*(.)");
assert_eq!(command.args[0].span.slice(source), "*(.)");
assert_eq!(
expect_zsh_glob_pattern_segment(segment).render_syntax(source),
"*"
);
assert_eq!(qualifiers.kind, ZshGlobQualifierKind::Classic);
assert_eq!(qualifiers.span.slice(source), "(.)");
assert!(matches!(
qualifiers.fragments.as_slice(),
[ZshGlobQualifier::Flag { name: '.', span }] if span.slice(source) == "."
));
}
#[test]
fn test_zsh_trailing_glob_qualifier_parses_star_slash() {
let source = "print *(/)\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
let glob = expect_zsh_qualified_glob(&command.args[0]);
let qualifiers = expect_zsh_glob_qualifiers(glob);
let [segment] = glob.segments.as_slice() else {
panic!("expected a single pattern segment");
};
assert_eq!(glob.span.slice(source), "*(/)");
assert_eq!(
expect_zsh_glob_pattern_segment(segment).render_syntax(source),
"*"
);
assert_eq!(qualifiers.kind, ZshGlobQualifierKind::Classic);
assert_eq!(qualifiers.span.slice(source), "(/)");
assert!(matches!(
qualifiers.fragments.as_slice(),
[ZshGlobQualifier::Flag { name: '/', span }] if span.slice(source) == "/"
));
}
#[test]
fn test_zsh_trailing_glob_qualifier_parses_star_n() {
let source = "print *(N)\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
let glob = expect_zsh_qualified_glob(&command.args[0]);
let qualifiers = expect_zsh_glob_qualifiers(glob);
let [segment] = glob.segments.as_slice() else {
panic!("expected a single pattern segment");
};
assert_eq!(glob.span.slice(source), "*(N)");
assert_eq!(
expect_zsh_glob_pattern_segment(segment).render_syntax(source),
"*"
);
assert_eq!(qualifiers.kind, ZshGlobQualifierKind::Classic);
assert_eq!(qualifiers.span.slice(source), "(N)");
assert!(matches!(
qualifiers.fragments.as_slice(),
[ZshGlobQualifier::Flag { name: 'N', span }] if span.slice(source) == "N"
));
}
#[test]
fn test_zsh_trailing_glob_qualifier_after_quoted_prefix_stays_single_argument() {
let source = "print \"$plugin_dir\"/*(:t)\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.args.len(), 1);
assert_eq!(command.args[0].span.slice(source), "\"$plugin_dir\"/*(:t)");
}
#[test]
fn test_zsh_trailing_glob_qualifier_inside_compound_array_stays_single_element() {
let source = "plugins=( \"$plugin_dir\"/*(:t) )\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.assignments.len(), 1);
let assignment = &command.assignments[0];
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.elements.len(), 1);
let ArrayElem::Sequential(first) = &array.elements[0] else {
panic!("expected sequential array element");
};
assert_eq!(first.span.slice(source), "\"$plugin_dir\"/*(:t)");
}
#[test]
fn test_zsh_quoted_variable_qualifier_inside_compound_array_stays_single_element() {
let source = "__GREP_ALIAS_CACHES=( \"$__GREP_CACHE_FILE\"(Nm-1) )\nif true; then :; fi\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.assignments.len(), 1);
let assignment = &command.assignments[0];
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.elements.len(), 1);
let ArrayElem::Sequential(first) = &array.elements[0] else {
panic!("expected sequential array element");
};
assert_eq!(first.span.slice(source), "\"$__GREP_CACHE_FILE\"(Nm-1)");
assert!(matches!(
output.file.body[1].command,
AstCommand::Compound(_)
));
}
#[test]
fn test_zsh_parameter_expansion_qualifier_inside_compound_array_stays_single_element() {
let source = "files=( $dir/${~pats}(N) )\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.assignments.len(), 1);
let assignment = &command.assignments[0];
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound array assignment");
};
assert_eq!(array.elements.len(), 1);
let ArrayElem::Sequential(first) = &array.elements[0] else {
panic!("expected sequential array element");
};
assert_eq!(first.span.slice(source), "$dir/${~pats}(N)");
}
#[test]
fn test_zsh_trailing_glob_qualifier_parses_recursive_pattern_with_letter_sequence_and_range() {
let source = "print **/*(.om[1,3])\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
let glob = expect_zsh_qualified_glob(&command.args[0]);
let qualifiers = expect_zsh_glob_qualifiers(glob);
let [segment] = glob.segments.as_slice() else {
panic!("expected a single pattern segment");
};
assert_eq!(glob.span.slice(source), "**/*(.om[1,3])");
assert_eq!(
expect_zsh_glob_pattern_segment(segment).render_syntax(source),
"**/*"
);
assert_eq!(qualifiers.kind, ZshGlobQualifierKind::Classic);
assert_eq!(qualifiers.span.slice(source), "(.om[1,3])");
let [
ZshGlobQualifier::Flag {
name: '.',
span: dot_span,
},
ZshGlobQualifier::LetterSequence {
text,
span: letters_span,
},
ZshGlobQualifier::NumericArgument {
span: range_span,
start,
end: Some(end),
},
] = qualifiers.fragments.as_slice()
else {
panic!("expected dot, letter sequence, and numeric range qualifiers");
};
assert_eq!(dot_span.slice(source), ".");
assert_eq!(letters_span.slice(source), "om");
assert_eq!(text.slice(source), "om");
assert_eq!(range_span.slice(source), "[1,3]");
assert_eq!(start.slice(source), "1");
assert_eq!(end.slice(source), "3");
}
#[test]
fn test_zsh_trailing_glob_qualifier_parses_prefixed_glob_with_negation() {
let source = "print foo*(^-)\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
let glob = expect_zsh_qualified_glob(&command.args[0]);
let qualifiers = expect_zsh_glob_qualifiers(glob);
let [segment] = glob.segments.as_slice() else {
panic!("expected a single pattern segment");
};
assert_eq!(glob.span.slice(source), "foo*(^-)");
assert_eq!(
expect_zsh_glob_pattern_segment(segment).render_syntax(source),
"foo*"
);
assert_eq!(qualifiers.kind, ZshGlobQualifierKind::Classic);
assert_eq!(qualifiers.span.slice(source), "(^-)");
let [
ZshGlobQualifier::Negation {
span: negation_span,
},
ZshGlobQualifier::Flag {
name: '-',
span: flag_span,
},
] = qualifiers.fragments.as_slice()
else {
panic!("expected negation and dash flag qualifiers");
};
assert_eq!(negation_span.slice(source), "^");
assert_eq!(flag_span.slice(source), "-");
}
#[test]
fn test_zsh_inline_glob_case_insensitive_control_preserves_segments() {
let source = "print (#i)*.jpg\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
let glob = expect_zsh_qualified_glob(&command.args[0]);
let [control, segment] = glob.segments.as_slice() else {
panic!("expected inline control followed by pattern segment");
};
let ZshGlobSegment::InlineControl(ZshInlineGlobControl::CaseInsensitive { span }) = control
else {
panic!("expected case-insensitive inline control");
};
assert_eq!(glob.span.slice(source), "(#i)*.jpg");
assert_eq!(span.slice(source), "(#i)");
assert_eq!(
expect_zsh_glob_pattern_segment(segment).render_syntax(source),
"*.jpg"
);
assert!(glob.qualifiers.is_none());
}
#[test]
fn test_zsh_inline_glob_backreference_control_preserves_segments() {
let source = "print (#b)(*)\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
let glob = expect_zsh_qualified_glob(&command.args[0]);
let [control, segment] = glob.segments.as_slice() else {
panic!("expected inline control followed by pattern segment");
};
let ZshGlobSegment::InlineControl(ZshInlineGlobControl::Backreferences { span }) = control
else {
panic!("expected backreference inline control");
};
assert_eq!(glob.span.slice(source), "(#b)(*)");
assert_eq!(span.slice(source), "(#b)");
assert_eq!(
expect_zsh_glob_pattern_segment(segment).render_syntax(source),
"(*)"
);
assert!(glob.qualifiers.is_none());
}
#[test]
fn test_zsh_inline_glob_anchor_controls_preserve_segments() {
let parser = Parser::with_dialect("", ShellDialect::Zsh);
let (start_len, start) = parser
.parse_zsh_inline_glob_control("(#s)", Position::new(), 0)
.expect("expected start-anchor control");
let (end_len, end) = parser
.parse_zsh_inline_glob_control("(#e)", Position::new(), 0)
.expect("expected end-anchor control");
assert_eq!(start_len, "(#s)".len());
assert_eq!(end_len, "(#e)".len());
assert!(matches!(
start,
ZshInlineGlobControl::StartAnchor { span } if span.slice("(#s)") == "(#s)"
));
assert!(matches!(
end,
ZshInlineGlobControl::EndAnchor { span } if span.slice("(#e)") == "(#e)"
));
}
#[test]
fn test_zsh_hash_q_glob_qualifier_parses_terminal_flag_group() {
let source = "print *.log(#qN)\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
let glob = expect_zsh_qualified_glob(&command.args[0]);
let qualifiers = expect_zsh_glob_qualifiers(glob);
let [segment] = glob.segments.as_slice() else {
panic!("expected a single pattern segment");
};
assert_eq!(glob.span.slice(source), "*.log(#qN)");
assert_eq!(
expect_zsh_glob_pattern_segment(segment).render_syntax(source),
"*.log"
);
assert_eq!(qualifiers.kind, ZshGlobQualifierKind::HashQ);
assert_eq!(qualifiers.span.slice(source), "(#qN)");
assert!(matches!(
qualifiers.fragments.as_slice(),
[ZshGlobQualifier::Flag { name: 'N', span }] if span.slice(source) == "N"
));
}
#[test]
fn test_zsh_hash_q_glob_qualifier_parses_recursive_pattern_with_letter_sequence_and_range() {
let source = "print **/*(#q.om[1,3])\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
let glob = expect_zsh_qualified_glob(&command.args[0]);
let qualifiers = expect_zsh_glob_qualifiers(glob);
let [segment] = glob.segments.as_slice() else {
panic!("expected a single pattern segment");
};
assert_eq!(glob.span.slice(source), "**/*(#q.om[1,3])");
assert_eq!(
expect_zsh_glob_pattern_segment(segment).render_syntax(source),
"**/*"
);
assert_eq!(qualifiers.kind, ZshGlobQualifierKind::HashQ);
assert_eq!(qualifiers.span.slice(source), "(#q.om[1,3])");
let [
ZshGlobQualifier::Flag {
name: '.',
span: dot_span,
},
ZshGlobQualifier::LetterSequence {
text,
span: letters_span,
},
ZshGlobQualifier::NumericArgument {
span: range_span,
start,
end: Some(end),
},
] = qualifiers.fragments.as_slice()
else {
panic!("expected dot, letter sequence, and numeric range qualifiers");
};
assert_eq!(dot_span.slice(source), ".");
assert_eq!(letters_span.slice(source), "om");
assert_eq!(text.slice(source), "om");
assert_eq!(range_span.slice(source), "[1,3]");
assert_eq!(start.slice(source), "1");
assert_eq!(end.slice(source), "3");
}
#[test]
fn test_zsh_glob_falls_back_for_unsupported_hash_control_group() {
let source = "print *(#a)\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
assert_eq!(command.args[0].span.slice(source), "*(#a)");
assert!(!matches!(
command.args[0].parts.as_slice(),
[WordPartNode {
kind: WordPart::ZshQualifiedGlob(_),
..
}]
));
}
#[test]
fn test_non_zsh_dialects_do_not_special_case_trailing_glob_qualifiers() {
for syntax in [
"*(.)",
"*(/)",
"*(N)",
"**/*(.om[1,3])",
"foo*(^-)",
"(#i)*.jpg",
"(#b)(*)",
"*.log(#qN)",
"**/*(#q.om[1,3])",
] {
let source = format!("print {syntax}\n");
for dialect in [ShellDialect::Bash, ShellDialect::Posix, ShellDialect::Mksh] {
let output = Parser::with_dialect(&source, dialect).parse().unwrap();
let AstCommand::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
assert_eq!(
command.args[0].span.slice(&source),
syntax,
"expected non-zsh dialect {dialect:?} to preserve {syntax:?} as a plain word",
);
assert!(
!matches!(
command.args[0].parts.as_slice(),
[WordPartNode {
kind: WordPart::ZshQualifiedGlob(_),
..
}]
),
"unexpected zsh-qualified glob node for {syntax:?} in {dialect:?}",
);
}
}
}
#[test]
fn test_zsh_repeat_do_done_preserves_structure_and_spans() {
let source = "repeat 3; do echo hi; done\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::Repeat(command) = compound else {
panic!("expected repeat command");
};
assert!(redirects.is_empty());
assert_eq!(command.span.slice(source), "repeat 3; do echo hi; done");
assert_eq!(command.count.span.slice(source), "3");
assert_eq!(command.body.len(), 1);
assert_eq!(command.body.span.slice(source), "echo hi; ");
match command.syntax {
RepeatSyntax::DoDone { do_span, done_span } => {
assert_eq!(do_span.slice(source), "do");
assert_eq!(done_span.slice(source), "done");
}
RepeatSyntax::Direct => panic!("expected do/done repeat syntax"),
RepeatSyntax::Brace { .. } => panic!("expected do/done repeat syntax"),
}
let body_command = expect_simple(&command.body[0]);
assert_eq!(body_command.name.render(source), "echo");
assert_eq!(body_command.args[0].render(source), "hi");
}
#[test]
fn test_zsh_repeat_brace_preserves_structure_and_spans() {
let source = "repeat 3 { echo hi; }\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::Repeat(command) = compound else {
panic!("expected repeat command");
};
assert!(redirects.is_empty());
assert_eq!(command.span.slice(source), "repeat 3 { echo hi; }");
assert_eq!(command.count.span.slice(source), "3");
assert_eq!(command.body.len(), 1);
assert_eq!(command.body.span.slice(source), "echo hi; ");
match command.syntax {
RepeatSyntax::Brace {
left_brace_span,
right_brace_span,
} => {
assert_eq!(left_brace_span.slice(source), "{");
assert_eq!(right_brace_span.slice(source), "}");
}
RepeatSyntax::Direct => panic!("expected brace repeat syntax"),
RepeatSyntax::DoDone { .. } => panic!("expected brace repeat syntax"),
}
let body_command = expect_simple(&command.body[0]);
assert_eq!(body_command.name.render(source), "echo");
assert_eq!(body_command.args[0].render(source), "hi");
}
#[test]
fn test_zsh_foreach_paren_brace_preserves_structure_and_spans() {
let source = "foreach x (a b c) { echo $x; }\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::Foreach(command) = compound else {
panic!("expected foreach command");
};
assert!(redirects.is_empty());
assert_eq!(command.span.slice(source), "foreach x (a b c) { echo $x; }");
assert_eq!(command.variable.as_str(), "x");
assert_eq!(command.variable_span.slice(source), "x");
assert_eq!(
command
.words
.iter()
.map(|word| word.span.slice(source))
.collect::<Vec<_>>(),
vec!["a", "b", "c"]
);
assert_eq!(command.body.len(), 1);
assert_eq!(command.body.span.slice(source), "echo $x; ");
match command.syntax {
ForeachSyntax::ParenBrace {
left_paren_span,
right_paren_span,
left_brace_span,
right_brace_span,
} => {
assert_eq!(left_paren_span.slice(source), "(");
assert_eq!(right_paren_span.slice(source), ")");
assert_eq!(left_brace_span.slice(source), "{");
assert_eq!(right_brace_span.slice(source), "}");
}
ForeachSyntax::InDoDone { .. } => panic!("expected paren/brace foreach syntax"),
}
let body_command = expect_simple(&command.body[0]);
assert_eq!(body_command.name.render(source), "echo");
assert_eq!(body_command.args[0].render(source), "$x");
}
#[test]
fn test_zsh_foreach_in_do_done_preserves_structure_and_spans() {
let source = "foreach x in a b c; do echo $x; done\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::Foreach(command) = compound else {
panic!("expected foreach command");
};
assert!(redirects.is_empty());
assert_eq!(
command.span.slice(source),
"foreach x in a b c; do echo $x; done"
);
assert_eq!(command.variable.as_str(), "x");
assert_eq!(command.variable_span.slice(source), "x");
assert_eq!(
command
.words
.iter()
.map(|word| word.span.slice(source))
.collect::<Vec<_>>(),
vec!["a", "b", "c"]
);
assert_eq!(command.body.len(), 1);
assert_eq!(command.body.span.slice(source), "echo $x; ");
match command.syntax {
ForeachSyntax::InDoDone {
in_span,
do_span,
done_span,
} => {
assert_eq!(in_span.slice(source), "in");
assert_eq!(do_span.slice(source), "do");
assert_eq!(done_span.slice(source), "done");
}
ForeachSyntax::ParenBrace { .. } => panic!("expected in/do/done foreach syntax"),
}
let body_command = expect_simple(&command.body[0]);
assert_eq!(body_command.name.render(source), "echo");
assert_eq!(body_command.args[0].render(source), "$x");
}
#[test]
fn test_non_zsh_dialects_reject_repeat_and_foreach_forms() {
for dialect in [ShellDialect::Bash, ShellDialect::Posix, ShellDialect::Mksh] {
for source in [
"repeat 3; do echo hi; done\n",
"repeat 3 { echo hi; }\n",
"foreach x (a b c) { echo $x; }\n",
"foreach x in a b c; do echo $x; done\n",
] {
let error = Parser::with_dialect(source, dialect).parse().unwrap_err();
assert!(
matches!(error, Error::Parse { .. }),
"expected parse error for {dialect:?} on {source:?}, got {error:?}"
);
}
}
}
#[test]
fn test_zsh_parameter_modifier_records_modifier_and_target() {
let source = "print ${(m)foo} ${(%):-%x}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let first = expect_parameter(&command.args[0]);
assert_eq!(first.raw_body.slice(source), "(m)foo");
let ParameterExpansionSyntax::Zsh(first) = &first.syntax else {
panic!("expected zsh parameter syntax");
};
assert_eq!(
first
.modifiers
.iter()
.map(|modifier| modifier.name)
.collect::<Vec<_>>(),
vec!['m']
);
let ZshExpansionTarget::Reference(reference) = &first.target else {
panic!("expected direct zsh reference target");
};
assert_eq!(reference.name.as_str(), "foo");
assert!(first.operation.is_none());
let second = expect_parameter(&command.args[1]);
assert_eq!(second.raw_body.slice(source), "(%):-%x");
let ParameterExpansionSyntax::Zsh(second) = &second.syntax else {
panic!("expected zsh parameter syntax");
};
assert_eq!(
second
.modifiers
.iter()
.map(|modifier| modifier.name)
.collect::<Vec<_>>(),
vec!['%']
);
assert!(matches!(second.target, ZshExpansionTarget::Empty));
assert!(matches!(
second.operation,
Some(ZshExpansionOperation::Defaulting {
kind: ZshDefaultingOp::UseDefault,
ref operand,
colon_variant: true,
..
}) if operand.slice(source) == "%x"
));
let defaulting = second
.operation
.as_ref()
.expect("expected defaulting operation");
assert_eq!(
defaulting
.operand_word_ast()
.expect("expected defaulting operand word")
.span
.slice(source),
"%x"
);
}
#[test]
fn test_zsh_parameter_modifier_groups_split_flags_and_preserve_delimited_args() {
let source = "print ${(Az)LBUFFER} ${(s./.)_p9k__cwd} ${(pj./.)parts[1,MATCH]}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let first = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Zsh(first) = &first.syntax else {
panic!("expected zsh parameter syntax");
};
assert_eq!(
first
.modifiers
.iter()
.map(|modifier| modifier.name)
.collect::<Vec<_>>(),
vec!['A', 'z']
);
assert!(
first
.modifiers
.iter()
.all(|modifier| modifier.span == first.modifiers[0].span)
);
let ZshExpansionTarget::Reference(reference) = &first.target else {
panic!("expected reference target");
};
assert_eq!(reference.name.as_str(), "LBUFFER");
let second = expect_parameter(&command.args[1]);
let ParameterExpansionSyntax::Zsh(second) = &second.syntax else {
panic!("expected zsh parameter syntax");
};
assert_eq!(
second
.modifiers
.iter()
.map(|modifier| modifier.name)
.collect::<Vec<_>>(),
vec!['s']
);
assert_eq!(second.modifiers[0].argument_delimiter, Some('.'));
assert_eq!(
second.modifiers[0]
.argument
.as_ref()
.expect("expected modifier argument")
.slice(source),
"/"
);
assert_eq!(
second.modifiers[0]
.argument_word_ast()
.expect("expected modifier argument word")
.span
.slice(source),
"/"
);
let ZshExpansionTarget::Reference(reference) = &second.target else {
panic!("expected reference target");
};
assert_eq!(reference.name.as_str(), "_p9k__cwd");
let third = expect_parameter(&command.args[2]);
let ParameterExpansionSyntax::Zsh(third) = &third.syntax else {
panic!("expected zsh parameter syntax");
};
assert_eq!(
third
.modifiers
.iter()
.map(|modifier| modifier.name)
.collect::<Vec<_>>(),
vec!['p', 'j']
);
assert!(
third
.modifiers
.iter()
.all(|modifier| modifier.span == third.modifiers[0].span)
);
assert_eq!(third.modifiers[1].argument_delimiter, Some('.'));
assert_eq!(
third.modifiers[1]
.argument
.as_ref()
.expect("expected modifier argument")
.slice(source),
"/"
);
assert_eq!(
third.modifiers[1]
.argument_word_ast()
.expect("expected modifier argument word")
.render(source),
"/"
);
let ZshExpansionTarget::Reference(reference) = &third.target else {
panic!("expected reference target");
};
assert_eq!(reference.name.as_str(), "parts");
let subscript = expect_subscript(reference, source, "1,MATCH");
assert_eq!(subscript.syntax_text(source), "1,MATCH");
}
#[test]
fn test_zsh_parameter_word_target_preserves_non_reference_target_text() {
let source = "print ${^$(pidof zsh):#$$}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let parameter = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Zsh(parameter) = ¶meter.syntax else {
panic!("expected zsh parameter syntax");
};
assert_eq!(
parameter
.modifiers
.iter()
.map(|modifier| modifier.name)
.collect::<Vec<_>>(),
vec!['^']
);
let ZshExpansionTarget::Word(word) = ¶meter.target else {
panic!("expected word target");
};
assert_eq!(word.render(source), "$(pidof zsh)");
assert!(matches!(
parameter.operation,
Some(ZshExpansionOperation::PatternOperation {
kind: ZshPatternOp::Filter,
ref operand,
..
}) if operand.slice(source) == "$$"
));
let operation = parameter
.operation
.as_ref()
.expect("expected pattern operation");
assert_eq!(
operation
.operand_word_ast()
.expect("expected pattern operand word")
.render(source),
"$$"
);
}
#[test]
fn test_zsh_parameter_bare_prefix_flags_record_modifier_sequence() {
let source = "print ${=name} ${~foo} ${^^bar} ${~~baz}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let split = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Zsh(split) = &split.syntax else {
panic!("expected zsh parameter syntax");
};
assert_eq!(
split
.modifiers
.iter()
.map(|modifier| modifier.name)
.collect::<Vec<_>>(),
vec!['=']
);
let ZshExpansionTarget::Reference(reference) = &split.target else {
panic!("expected split target reference");
};
assert_eq!(reference.name.as_str(), "name");
let glob = expect_parameter(&command.args[1]);
let ParameterExpansionSyntax::Zsh(glob) = &glob.syntax else {
panic!("expected zsh parameter syntax");
};
assert_eq!(
glob.modifiers
.iter()
.map(|modifier| modifier.name)
.collect::<Vec<_>>(),
vec!['~']
);
let ZshExpansionTarget::Reference(reference) = &glob.target else {
panic!("expected glob target reference");
};
assert_eq!(reference.name.as_str(), "foo");
let rc_expand = expect_parameter(&command.args[2]);
let ParameterExpansionSyntax::Zsh(rc_expand) = &rc_expand.syntax else {
panic!("expected zsh parameter syntax");
};
assert_eq!(
rc_expand
.modifiers
.iter()
.map(|modifier| modifier.name)
.collect::<Vec<_>>(),
vec!['^', '^']
);
let ZshExpansionTarget::Reference(reference) = &rc_expand.target else {
panic!("expected rc-expand target reference");
};
assert_eq!(reference.name.as_str(), "bar");
let glob_off = expect_parameter(&command.args[3]);
let ParameterExpansionSyntax::Zsh(glob_off) = &glob_off.syntax else {
panic!("expected zsh parameter syntax");
};
assert_eq!(
glob_off
.modifiers
.iter()
.map(|modifier| modifier.name)
.collect::<Vec<_>>(),
vec!['~', '~']
);
let ZshExpansionTarget::Reference(reference) = &glob_off.target else {
panic!("expected glob-off target reference");
};
assert_eq!(reference.name.as_str(), "baz");
}
#[test]
fn test_zsh_parameter_word_target_accepts_quoted_command_substitution_text() {
let source = "print ${\"$(xcode-select -p)\"%%/Contents/Developer*}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let parameter = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Zsh(parameter) = ¶meter.syntax else {
panic!("expected zsh parameter syntax");
};
assert!(parameter.length_prefix.is_none());
let ZshExpansionTarget::Word(word) = ¶meter.target else {
panic!("expected quoted word target");
};
assert_eq!(word.span.slice(source), "\"$(xcode-select -p)\"");
assert!(matches!(
parameter.operation,
Some(ZshExpansionOperation::TrimOperation {
kind: ZshTrimOp::RemoveSuffixLong,
ref operand,
..
}) if operand.slice(source) == "/Contents/Developer*"
));
let operation = parameter
.operation
.as_ref()
.expect("expected trim operation");
assert_eq!(
operation
.operand_word_ast()
.expect("expected trim operand word")
.span
.slice(source),
"/Contents/Developer*"
);
}
#[test]
fn test_zsh_parameter_length_prefix_preserves_nested_replacement_target() {
let source = "print ${#${cd//${~q}/}}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let parameter = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Zsh(parameter) = ¶meter.syntax else {
panic!("expected zsh parameter syntax");
};
assert_eq!(
parameter
.length_prefix
.expect("expected zsh length prefix")
.slice(source),
"#"
);
let ZshExpansionTarget::Nested(inner) = ¶meter.target else {
panic!("expected nested zsh target");
};
assert_eq!(inner.raw_body.slice(source), "cd//${~q}/");
let ParameterExpansionSyntax::Zsh(inner) = &inner.syntax else {
panic!("expected nested zsh syntax");
};
let ZshExpansionTarget::Reference(reference) = &inner.target else {
panic!("expected nested replacement target reference");
};
assert_eq!(reference.name.as_str(), "cd");
assert!(matches!(
inner.operation,
Some(ZshExpansionOperation::ReplacementOperation {
kind: ZshReplacementOp::ReplaceAll,
ref pattern,
replacement: Some(ref replacement),
..
}) if pattern.slice(source) == "${~q}" && replacement.slice(source).is_empty()
));
let operation = inner
.operation
.as_ref()
.expect("expected replacement operation");
assert_eq!(
operation
.pattern_word_ast()
.expect("expected replacement pattern word")
.span
.slice(source),
"${~q}"
);
}
#[test]
fn test_zsh_parameter_colon_modifiers_preserve_targets_without_bourne_slice_offsets() {
let source = "print ${REPLY:l} ${1:t} ${0:h}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let reply = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Zsh(reply) = &reply.syntax else {
panic!("expected zsh parameter syntax");
};
let ZshExpansionTarget::Reference(reference) = &reply.target else {
panic!("expected reference target");
};
assert_eq!(reference.name.as_str(), "REPLY");
assert!(matches!(
reply.operation,
Some(ZshExpansionOperation::Unknown { ref text, .. }) if text.slice(source) == ":l"
));
let first = expect_parameter(&command.args[1]);
let ParameterExpansionSyntax::Zsh(first) = &first.syntax else {
panic!("expected zsh parameter syntax");
};
let ZshExpansionTarget::Reference(reference) = &first.target else {
panic!("expected positional reference target");
};
assert_eq!(reference.name.as_str(), "1");
assert!(matches!(
first.operation,
Some(ZshExpansionOperation::Unknown { ref text, .. }) if text.slice(source) == ":t"
));
let zero = expect_parameter(&command.args[2]);
let ParameterExpansionSyntax::Zsh(zero) = &zero.syntax else {
panic!("expected zsh parameter syntax");
};
let ZshExpansionTarget::Reference(reference) = &zero.target else {
panic!("expected script-name reference target");
};
assert_eq!(reference.name.as_str(), "0");
assert!(matches!(
zero.operation,
Some(ZshExpansionOperation::Unknown { ref text, .. }) if text.slice(source) == ":h"
));
let operation = zero.operation.as_ref().expect("expected unknown operation");
assert_eq!(
operation
.operand_word_ast()
.expect("expected unknown operation word")
.render(source),
":h"
);
}
#[test]
fn test_zsh_parameter_colon_modifiers_with_digits_preserve_targets() {
let source = "print ${path:A:h3} ${path:t2}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let first = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Zsh(first) = &first.syntax else {
panic!("expected zsh parameter syntax");
};
let ZshExpansionTarget::Reference(reference) = &first.target else {
panic!("expected reference target");
};
assert_eq!(reference.name.as_str(), "path");
assert!(matches!(
first.operation,
Some(ZshExpansionOperation::Unknown { ref text, .. }) if text.slice(source) == ":A:h3"
));
let second = expect_parameter(&command.args[1]);
let ParameterExpansionSyntax::Zsh(second) = &second.syntax else {
panic!("expected zsh parameter syntax");
};
let ZshExpansionTarget::Reference(reference) = &second.target else {
panic!("expected reference target");
};
assert_eq!(reference.name.as_str(), "path");
assert!(matches!(
second.operation,
Some(ZshExpansionOperation::Unknown { ref text, .. }) if text.slice(source) == ":t2"
));
}
#[test]
fn test_zsh_plain_positional_parameters_preserve_bourne_references() {
let source = "print ${1} ${10} ${#1} ${#10}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let first = expect_parameter(&command.args[0]);
assert!(matches!(
&first.syntax,
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference })
if reference.name.as_str() == "1"
));
let second = expect_parameter(&command.args[1]);
assert!(matches!(
&second.syntax,
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference })
if reference.name.as_str() == "10"
));
let third = expect_parameter(&command.args[2]);
assert!(matches!(
&third.syntax,
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Length { reference })
if reference.name.as_str() == "1"
));
let fourth = expect_parameter(&command.args[3]);
assert!(matches!(
&fourth.syntax,
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Length { reference })
if reference.name.as_str() == "10"
));
}
#[test]
fn test_parse_zsh_array_assignment_with_word_target_and_glob_qualifier() {
let source = "dirs=( /proc/${^$(pidof zsh):#$$}/cwd(N:A) )\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
assert_eq!(command.assignments.len(), 1);
let AssignmentValue::Compound(array) = &command.assignments[0].value else {
panic!("expected compound assignment value");
};
assert_eq!(
array.span.slice(source),
"( /proc/${^$(pidof zsh):#$$}/cwd(N:A) )"
);
}
#[test]
fn test_parse_zsh_assignment_with_nested_subscript_pattern_range() {
let source = "in_alias=($in_alias[$in_alias[(i)<1->],-1])\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
assert_eq!(command.assignments.len(), 1);
let AssignmentValue::Compound(array) = &command.assignments[0].value else {
panic!("expected compound assignment value");
};
assert_eq!(
array.span.slice(source),
"($in_alias[$in_alias[(i)<1->],-1])"
);
}
#[test]
fn test_parse_zsh_nested_join_modifier_inside_replacement_word() {
let source =
"_p9k__parent_dirs=(${(@)${:-{$#parts..1}}/(#m)*/$parent${(pj./.)parts[1,MATCH]}})\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
assert_eq!(command.assignments.len(), 1);
let AssignmentValue::Compound(array) = &command.assignments[0].value else {
panic!("expected compound assignment value");
};
assert_eq!(
array.span.slice(source),
"(${(@)${:-{$#parts..1}}/(#m)*/$parent${(pj./.)parts[1,MATCH]}})"
);
}
#[test]
fn test_parse_zsh_compound_array_ignores_trailing_comments() {
let source = "opts=(\n 'grc' :se # grc - a \"generic colouriser\" (that\\'s their spelling, not mine)\n 'cpulimit' elp:ivz # cpulimit 0.2\n)\n";
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
}
#[test]
fn test_parse_zsh_associative_array_literal_with_blank_lines_and_comments() {
let source = "local -A precommand_options\nprecommand_options=(\n # Precommand modifiers as of zsh 5.6.2 cf. zshmisc(1).\n '-' ''\n 'builtin' ''\n 'command' :pvV\n 'exec' a:cl\n 'noglob' ''\n # 'time' and 'nocorrect' shouldn't be added here; they're reserved words, not precommands.\n\n # miscellaneous commands\n 'doas' aCu:Lns # as of OpenBSD's doas(1) dated September 4, 2016\n 'nice' n: # as of current POSIX spec\n 'pkexec' '' # doesn't take short options; immune to #121 because it's usually not passed --option flags\n # Not listed: -h, which has two different meanings.\n 'sudo' Cgprtu:AEHPSbilns:eKkVv # as of sudo 1.8.21p2\n 'stdbuf' ioe:\n 'eatmydata' ''\n 'catchsegv' ''\n 'nohup' ''\n 'setsid' :wc\n 'env' u:i\n 'ionice' cn:t:pPu # util-linux 2.33.1-0.1\n 'strace' IbeaosXPpEuOS:ACdfhikqrtTvVxyDc # strace 4.26-0.2\n 'proxychains' f:q # proxychains 4.4.0\n 'torsocks' idq:upaP # Torsocks 2.3.0\n 'torify' idq:upaP # Torsocks 2.3.0\n 'ssh-agent' aEPt:csDd:k # As of OpenSSH 8.1p1\n 'tabbed' gnprtTuU:cdfhs:v # suckless-tools v44\n 'chronic' :ev # moreutils 0.62-1\n 'ifne' :n # moreutils 0.62-1\n 'grc' :se # grc - a \"generic colouriser\" (that's their spelling, not mine)\n 'cpulimit' elp:ivz # cpulimit 0.2\n 'ktrace' fgpt:aBCcdiT\n 'caffeinate' tw:dimsu # as of macOS's caffeinate(8) dated November 9, 2012\n)\n";
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
}
#[test]
fn test_parse_zsh_compound_array_with_nested_groups_and_qualifiers() {
let source = "local -a bats=( /sys/class/power_supply/(CMB*|BAT*|*battery)/(FN) )\n";
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
}
#[test]
fn test_parse_zsh_arithmetic_shell_word_lookup_with_nested_modifier() {
let source = "(( e = ${tokens[(i)${(Q)token}]} ))\n";
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
}
#[test]
fn test_parse_zsh_arithmetic_shell_word_with_short_length_subscript_pattern() {
let source = "if (( ! $#functions[(i)n(odenv|vm)] )); then\n return 1\nfi\n";
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
}
#[test]
fn test_parse_zsh_arithmetic_shell_word_preserves_nested_length_target() {
let source = "(( q_chars = ${#${cd//${~q}/}} ))\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, _) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::Arithmetic(command) = compound else {
panic!("expected arithmetic command");
};
let expr = command.expr_ast.as_ref().expect("expected arithmetic AST");
let ArithmeticExpr::Assignment { value, .. } = &expr.kind else {
panic!("expected arithmetic assignment");
};
let ArithmeticExpr::ShellWord(word) = &value.kind else {
panic!("expected arithmetic shell word");
};
let parameter = expect_parameter(word);
let ParameterExpansionSyntax::Zsh(parameter) = ¶meter.syntax else {
panic!("expected zsh parameter syntax");
};
assert_eq!(
parameter
.length_prefix
.expect("expected zsh length prefix")
.slice(source),
"#"
);
let ZshExpansionTarget::Nested(inner) = ¶meter.target else {
panic!("expected nested zsh target");
};
let ParameterExpansionSyntax::Zsh(inner) = &inner.syntax else {
panic!("expected nested zsh syntax");
};
let ZshExpansionTarget::Reference(reference) = &inner.target else {
panic!("expected nested reference target");
};
assert_eq!(reference.name.as_str(), "cd");
assert!(matches!(
inner.operation,
Some(ZshExpansionOperation::ReplacementOperation {
kind: ZshReplacementOp::ReplaceAll,
ref pattern,
replacement: Some(ref replacement),
..
}) if pattern.slice(source) == "${~q}" && replacement.slice(source).is_empty()
));
}
#[test]
fn test_zsh_parameter_identifier_slices_preserve_legacy_slice_parts() {
let source = "print ${foo:i} ${foo:i:j}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let (first_reference, first_offset_ast, first_length_ast) =
expect_substring_part(&command.args[0].parts[0].kind);
assert_eq!(first_reference.name.as_str(), "foo");
assert_eq!(
first_offset_ast
.as_ref()
.expect("expected first offset AST")
.span
.slice(source),
"i"
);
assert!(first_length_ast.is_none());
let (second_reference, second_offset_ast, second_length_ast) =
expect_substring_part(&command.args[1].parts[0].kind);
assert_eq!(second_reference.name.as_str(), "foo");
assert_eq!(
second_offset_ast
.as_ref()
.expect("expected second offset AST")
.span
.slice(source),
"i"
);
assert_eq!(
second_length_ast
.as_ref()
.expect("expected second length AST")
.span
.slice(source),
"j"
);
}
#[test]
fn test_zsh_parameter_identifier_slices_stay_typed_in_zsh_parameter_nodes() {
let source = "print ${(m)foo:i:j}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let parameter = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Zsh(parameter) = ¶meter.syntax else {
panic!("expected zsh parameter syntax");
};
assert_eq!(
parameter
.modifiers
.iter()
.map(|modifier| modifier.name)
.collect::<Vec<_>>(),
vec!['m']
);
let ZshExpansionTarget::Reference(reference) = ¶meter.target else {
panic!("expected reference target");
};
assert_eq!(reference.name.as_str(), "foo");
assert!(matches!(
parameter.operation,
Some(ZshExpansionOperation::Slice {
ref offset,
length: Some(ref length),
..
}) if offset.slice(source) == "i" && length.slice(source) == "j"
));
let operation = parameter
.operation
.as_ref()
.expect("expected slice operation");
assert_eq!(
operation
.offset_word_ast()
.expect("expected slice offset word")
.render(source),
"i"
);
assert_eq!(
operation
.length_word_ast()
.expect("expected slice length word")
.render(source),
"j"
);
}
#[test]
fn test_zsh_nested_parameter_modifier_records_nested_target_and_pattern_operation() {
let source = "print ${(M)${(k)parameters[@]}:#__gitcomp_builtin_*}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let parameter = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Zsh(parameter) = ¶meter.syntax else {
panic!("expected zsh parameter syntax");
};
assert_eq!(
parameter
.modifiers
.iter()
.map(|modifier| modifier.name)
.collect::<Vec<_>>(),
vec!['M']
);
let ZshExpansionTarget::Nested(inner) = ¶meter.target else {
panic!("expected nested zsh parameter target");
};
let ParameterExpansionSyntax::Zsh(inner) = &inner.syntax else {
panic!("expected nested zsh syntax");
};
assert_eq!(
inner
.modifiers
.iter()
.map(|modifier| modifier.name)
.collect::<Vec<_>>(),
vec!['k']
);
let ZshExpansionTarget::Reference(reference) = &inner.target else {
panic!("expected nested reference target");
};
assert_eq!(reference.name.as_str(), "parameters");
assert!(reference.has_array_selector());
assert!(matches!(
parameter.operation,
Some(ZshExpansionOperation::PatternOperation {
kind: ZshPatternOp::Filter,
ref operand,
..
}) if operand.slice(source) == "__gitcomp_builtin_*"
));
}
#[test]
fn test_zsh_nested_plain_access_targets_preserve_bourne_refs_without_modifier_regression() {
let source = "print ${${10}} ${#${10}} ${${#}} ${${$}} ${${1:t}}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let nested = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Zsh(nested) = &nested.syntax else {
panic!("expected outer zsh syntax");
};
let ZshExpansionTarget::Nested(inner) = &nested.target else {
panic!("expected nested target");
};
assert!(matches!(
&inner.syntax,
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference })
if reference.name.as_str() == "10"
));
let length = expect_parameter(&command.args[1]);
let ParameterExpansionSyntax::Zsh(length) = &length.syntax else {
panic!("expected outer zsh syntax");
};
assert_eq!(
length
.length_prefix
.expect("expected zsh length prefix")
.slice(source),
"#"
);
let ZshExpansionTarget::Nested(inner) = &length.target else {
panic!("expected nested length target");
};
assert!(matches!(
&inner.syntax,
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference })
if reference.name.as_str() == "10"
));
let count = expect_parameter(&command.args[2]);
let ParameterExpansionSyntax::Zsh(count) = &count.syntax else {
panic!("expected outer zsh syntax");
};
let ZshExpansionTarget::Nested(inner) = &count.target else {
panic!("expected nested count target");
};
assert!(matches!(
&inner.syntax,
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference })
if reference.name.as_str() == "#"
));
let pid = expect_parameter(&command.args[3]);
let ParameterExpansionSyntax::Zsh(pid) = &pid.syntax else {
panic!("expected outer zsh syntax");
};
let ZshExpansionTarget::Nested(inner) = &pid.target else {
panic!("expected nested pid target");
};
assert!(matches!(
&inner.syntax,
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference })
if reference.name.as_str() == "$"
));
let modifier = expect_parameter(&command.args[4]);
let ParameterExpansionSyntax::Zsh(modifier) = &modifier.syntax else {
panic!("expected outer zsh syntax");
};
let ZshExpansionTarget::Nested(inner) = &modifier.target else {
panic!("expected nested modifier target");
};
let ParameterExpansionSyntax::Zsh(inner) = &inner.syntax else {
panic!("expected inner zsh syntax");
};
let ZshExpansionTarget::Reference(reference) = &inner.target else {
panic!("expected positional reference target");
};
assert_eq!(reference.name.as_str(), "1");
assert!(matches!(
inner.operation,
Some(ZshExpansionOperation::Unknown { ref text, .. }) if text.slice(source) == ":t"
));
}
#[test]
fn test_zsh_parameter_supported_operations_are_typed_and_preserve_source_spans() {
let source = "print ${(m)foo#${needle}} ${(S)foo//\"pre\"$suffix/$replacement} ${(m)foo:$offset:${length}} ${(m)foo:^other}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let trim = expect_parameter(&command.args[0]);
assert_eq!(trim.raw_body.slice(source), "(m)foo#${needle}");
let ParameterExpansionSyntax::Zsh(trim) = &trim.syntax else {
panic!("expected zsh parameter syntax");
};
assert!(matches!(
trim.operation,
Some(ZshExpansionOperation::TrimOperation {
kind: ZshTrimOp::RemovePrefixShort,
ref operand,
..
}) if operand.is_source_backed() && operand.slice(source) == "${needle}"
));
let trim_operation = trim.operation.as_ref().expect("expected trim operation");
assert_eq!(
trim_operation
.operand_word_ast()
.expect("expected trim operand word")
.span
.slice(source),
"${needle}"
);
let replacement = expect_parameter(&command.args[1]);
assert_eq!(
replacement.raw_body.slice(source),
"(S)foo//\"pre\"$suffix/$replacement"
);
let ParameterExpansionSyntax::Zsh(replacement) = &replacement.syntax else {
panic!("expected zsh parameter syntax");
};
assert!(matches!(
replacement.operation,
Some(ZshExpansionOperation::ReplacementOperation {
kind: ZshReplacementOp::ReplaceAll,
ref pattern,
replacement: Some(ref replacement),
..
}) if pattern.is_source_backed()
&& pattern.slice(source) == "\"pre\"$suffix"
&& replacement.is_source_backed()
&& replacement.slice(source) == "$replacement"
));
let replacement_operation = replacement
.operation
.as_ref()
.expect("expected replacement operation");
assert_eq!(
replacement_operation
.pattern_word_ast()
.expect("expected replacement pattern word")
.span
.slice(source),
"\"pre\"$suffix"
);
assert_eq!(
replacement_operation
.replacement_word_ast()
.expect("expected replacement word")
.span
.slice(source),
"$replacement"
);
let slice = expect_parameter(&command.args[2]);
assert_eq!(slice.raw_body.slice(source), "(m)foo:$offset:${length}");
let ParameterExpansionSyntax::Zsh(slice) = &slice.syntax else {
panic!("expected zsh parameter syntax");
};
assert!(matches!(
slice.operation,
Some(ZshExpansionOperation::Slice {
ref offset,
length: Some(ref length),
..
}) if offset.is_source_backed()
&& offset.slice(source) == "$offset"
&& length.is_source_backed()
&& length.slice(source) == "${length}"
));
let slice_operation = slice.operation.as_ref().expect("expected slice operation");
assert_eq!(
slice_operation
.offset_word_ast()
.expect("expected slice offset word")
.span
.slice(source),
"$offset"
);
assert_eq!(
slice_operation
.length_word_ast()
.expect("expected slice length word")
.span
.slice(source),
"${length}"
);
let unknown = expect_parameter(&command.args[3]);
assert_eq!(unknown.raw_body.slice(source), "(m)foo:^other");
let ParameterExpansionSyntax::Zsh(unknown) = &unknown.syntax else {
panic!("expected zsh parameter syntax");
};
assert!(matches!(
unknown.operation,
Some(ZshExpansionOperation::Unknown { ref text, .. })
if text.is_source_backed() && text.slice(source) == ":^other"
));
let unknown_operation = unknown
.operation
.as_ref()
.expect("expected unknown operation");
assert_eq!(
unknown_operation
.operand_word_ast()
.expect("expected unknown operation word")
.span
.slice(source),
":^other"
);
}
#[test]
fn test_zsh_parameter_operation_kinds_cover_long_trim_and_anchored_replacement() {
let source = "print ${(m)foo##pre*} ${(m)foo%%post*} ${(S)foo/#$prefix/$replacement} ${(S)foo/%$suffix/$replacement}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
let first = expect_parameter(&command.args[0]);
let ParameterExpansionSyntax::Zsh(first) = &first.syntax else {
panic!("expected zsh parameter syntax");
};
assert!(matches!(
first.operation,
Some(ZshExpansionOperation::TrimOperation {
kind: ZshTrimOp::RemovePrefixLong,
ref operand,
..
}) if operand.slice(source) == "pre*"
));
let second = expect_parameter(&command.args[1]);
let ParameterExpansionSyntax::Zsh(second) = &second.syntax else {
panic!("expected zsh parameter syntax");
};
assert!(matches!(
second.operation,
Some(ZshExpansionOperation::TrimOperation {
kind: ZshTrimOp::RemoveSuffixLong,
ref operand,
..
}) if operand.slice(source) == "post*"
));
let third = expect_parameter(&command.args[2]);
let ParameterExpansionSyntax::Zsh(third) = &third.syntax else {
panic!("expected zsh parameter syntax");
};
assert!(matches!(
third.operation,
Some(ZshExpansionOperation::ReplacementOperation {
kind: ZshReplacementOp::ReplacePrefix,
ref pattern,
replacement: Some(ref replacement),
..
}) if pattern.slice(source) == "$prefix" && replacement.slice(source) == "$replacement"
));
let fourth = expect_parameter(&command.args[3]);
let ParameterExpansionSyntax::Zsh(fourth) = &fourth.syntax else {
panic!("expected zsh parameter syntax");
};
assert!(matches!(
fourth.operation,
Some(ZshExpansionOperation::ReplacementOperation {
kind: ZshReplacementOp::ReplaceSuffix,
ref pattern,
replacement: Some(ref replacement),
..
}) if pattern.slice(source) == "$suffix" && replacement.slice(source) == "$replacement"
));
}
#[test]
fn test_zsh_brace_if_records_brace_syntax() {
let source =
"if [[ -n $foo ]] { print foo; } elif [[ -n $bar ]] { print bar; } else { print baz; }\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, _) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::If(command) = compound else {
panic!("expected if command");
};
assert!(matches!(
command.syntax,
IfSyntax::Brace {
left_brace_span,
right_brace_span,
} if left_brace_span.slice(source) == "{" && right_brace_span.slice(source) == "}"
));
assert_eq!(command.elif_branches.len(), 1);
assert!(command.else_branch.is_some());
}
#[test]
fn test_zsh_brace_if_allows_brace_group_elif_conditions() {
let source = "if [[ $profile == ./* || $profile == /* ]] {\n local localpkg=1\n} elif { ! .zinit-download-file-stdout $URL 0 1 2>/dev/null > $tmpfile } {\n command rm -f $tmpfile\n}\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, _) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::If(command) = compound else {
panic!("expected if command");
};
assert_eq!(command.elif_branches.len(), 1);
assert_eq!(command.elif_branches[0].0.len(), 1);
assert!(matches!(
command.elif_branches[0].0[0].command,
AstCommand::Compound(_)
));
}
#[test]
fn test_zsh_brace_if_allows_same_line_closing_braces_without_semicolons() {
let source =
"if [[ -n $foo ]] { print foo } elif [[ -n $bar ]] { print bar } else { print baz }\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, _) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::If(command) = compound else {
panic!("expected if command");
};
assert_eq!(command.then_branch.len(), 1);
assert_eq!(command.elif_branches.len(), 1);
assert!(command.else_branch.is_some());
assert_eq!(
expect_simple(&command.then_branch[0]).name.render(source),
"print"
);
assert_eq!(
expect_simple(&command.elif_branches[0].1[0])
.name
.render(source),
"print"
);
assert_eq!(
expect_simple(&command.else_branch.as_ref().unwrap()[0])
.name
.render(source),
"print"
);
}
#[test]
fn test_zsh_if_condition_allows_compact_brace_group_before_then_separator() {
let source = "\
if zstyle -t ':omz:alpha:lib:git' async-prompt \\
|| { is-at-least 5.0.6 && zstyle -T ':omz:alpha:lib:git' async-prompt }; then
print ok
fi
";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, _) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::If(command) = compound else {
panic!("expected if command");
};
assert_eq!(command.then_branch.len(), 1);
assert_eq!(
expect_simple(&command.then_branch[0]).name.render(source),
"print"
);
}
#[test]
fn test_zsh_if_condition_can_start_with_brace_group() {
let source = "\
if { ! . \"$srcdir\"/\"$ARG\" } || (( $#fail_test )); then
print ok
fi
";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, _) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::If(command) = compound else {
panic!("expected if command");
};
assert_eq!(command.condition.len(), 1);
assert_eq!(command.then_branch.len(), 1);
assert_eq!(
expect_simple(&command.then_branch[0]).name.render(source),
"print"
);
}
#[test]
fn test_zsh_comment_only_elif_body_is_preserved_on_branch() {
let source = "\
if true; then
print ok
elif false; then
# keep this branch for later
fi
";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, _) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::If(command) = compound else {
panic!("expected if command");
};
assert_eq!(command.elif_branches.len(), 1);
let elif_body = &command.elif_branches[0].1;
assert!(elif_body.is_empty());
assert_eq!(elif_body.trailing_comments.len(), 1);
let comment = elif_body.trailing_comments[0];
let start = usize::from(comment.range.start());
let end = usize::from(comment.range.end());
assert_eq!(&source[start..end], "# keep this branch for later");
}
#[test]
fn test_parse_zsh_repeat_with_inline_simple_command_body() {
let source = "repeat $difference do left_column+=(.); done\n";
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
}
#[test]
fn test_parse_zsh_repeat_with_direct_assignment_body() {
let source =
"repeat $((num_right_lines - num_left_lines)) left_segments=(newline $left_segments)\n";
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
}
#[test]
fn test_parse_zsh_function_keyword_with_spaced_empty_brace_body() {
let source = "function battery_time_remaining() { } # Not available on android\n";
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
}
#[test]
fn test_parse_zsh_compact_function_body_with_background_pipe_and_trailing_semicolon() {
let source = "function clipcopy() { cat \"${1:-/dev/stdin}\" | wl-copy &>/dev/null &|; }\n";
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
}
#[test]
fn test_parse_zsh_git_extras_style_quoted_continuations_inside_assignment() {
let source = "tag_names=(${${(f)\"$(_call_program tags git for-each-ref --format='\"%(refname)\"' refs/tags 2>/dev/null)\"}#refs/tags/})\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let command = expect_simple(&output.file.body[0]);
assert_eq!(command.assignments.len(), 1);
assert!(command.args.is_empty());
let assignment = &command.assignments[0];
assert_eq!(assignment.target.name, "tag_names");
let AssignmentValue::Compound(array) = &assignment.value else {
panic!("expected compound assignment value");
};
assert_eq!(array.elements.len(), 1);
let ArrayElem::Sequential(value) = &array.elements[0] else {
panic!("expected sequential array element");
};
assert_eq!(
value.span.slice(source),
"${${(f)\"$(_call_program tags git for-each-ref --format='\"%(refname)\"' refs/tags 2>/dev/null)\"}#refs/tags/}"
);
}
#[test]
fn test_parse_zsh_parameter_default_with_prompt_escape_text() {
let source = "color_green=${BATTERY_COLOR_GREEN:-%F{green}}\n";
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
}
#[test]
fn test_parse_zsh_force_append_redirect() {
let source = "print -lr -- ${p}${^*} >>| $SCD_HISTFILE\n";
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
}
#[test]
fn test_parse_zsh_assignment_with_escaped_literal_parameter_template_in_double_quotes() {
let source = "IFS=$'\\1' _p9k__param_pat+=\"${(@)${(@o)parameters[(I)POWERLEVEL9K_*]}:/(#m)*/\\${${(q)MATCH}-$IFS\\}}\"\n";
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
}
#[test]
fn test_parse_zsh_anonymous_function_invocation_with_nested_replacement_word() {
let source = r#"if [[ -t 1 ]]; then
if (( ${+__p9k_use_osc133_c_cmdline} )); then
() {
emulate -L zsh -o extended_glob -o no_multibyte
local MATCH MBEGIN MEND
builtin printf '\e]133;C;cmdline_url=%s\a' "${1//(#m)[^a-zA-Z0-9"\/:_.-!'()~"]/%${(l:2::0:)$(([##16]#MATCH))}}"
} "$1"
else
builtin print -n '\e]133;C;\a'
fi
fi
"#;
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
}
#[test]
fn test_parse_zsh_standalone_anonymous_function_invocation_with_nested_replacement_word() {
let source = r#"() {
emulate -L zsh -o extended_glob -o no_multibyte
local MATCH MBEGIN MEND
builtin printf '\e]133;C;cmdline_url=%s\a' "${1//(#m)[^a-zA-Z0-9"\/:_.-!'()~"]/%${(l:2::0:)$(([##16]#MATCH))}}"
} "$1"
"#;
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
}
#[test]
fn test_zsh_truly_empty_elif_body_is_still_rejected() {
let source = "\
if true; then
print ok
elif false; then
fi
";
assert!(
Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.is_err(),
"expected zsh empty elif without comments to stay rejected",
);
}
#[test]
fn test_zsh_if_condition_brace_group_keeps_closing_brace_out_of_arguments() {
let source =
"if (( fd != -1 && pid != -1 )) && { true <&$fd } 2>/dev/null; then\n echo ok\nfi\n";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, redirects) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::If(command) = compound else {
panic!("expected if command");
};
assert!(redirects.is_empty());
assert_eq!(command.condition.len(), 1);
let condition = expect_binary(&command.condition[0]);
assert_eq!(condition.op, BinaryOp::And);
let (body_compound, body_redirects) = expect_compound(&condition.right);
let AstCompoundCommand::BraceGroup(body) = body_compound else {
panic!("expected brace group on right-hand side of &&");
};
assert_eq!(body.len(), 1);
let inner = expect_simple(&body[0]);
assert_eq!(inner.name.render(source), "true");
assert!(inner.args.is_empty());
assert_eq!(body[0].redirects.len(), 1);
assert_eq!(body[0].redirects[0].kind, RedirectKind::DupInput);
assert_eq!(
redirect_word_target(&body[0].redirects[0]).render(source),
"$fd"
);
assert_eq!(body_redirects.len(), 1);
assert_eq!(body_redirects[0].fd, Some(2));
assert_eq!(body_redirects[0].kind, RedirectKind::Output);
assert_eq!(
redirect_word_target(&body_redirects[0]).render(source),
"/dev/null"
);
assert_eq!(command.then_branch.len(), 1);
assert_eq!(
expect_simple(&command.then_branch[0]).name.render(source),
"echo"
);
}
#[test]
fn test_parse_case_arm_with_fd_clobber_redirect() {
let source = "case $# in\n 0) shellspec_yield 2>|\"$SHELLSPEC_LEAK_FILE\" ;;\n *) shellspec_yield \"$@\" 2>|\"$SHELLSPEC_LEAK_FILE\" ;;\nesac\n";
Parser::new(source).parse().unwrap();
}
#[test]
fn test_zsh_always_and_background_operators_preserve_surface_forms() {
let source = "\
{ print body; } always { print cleanup; }
print quiet &|
print hidden &!
";
let output = Parser::with_dialect(source, ShellDialect::Zsh)
.parse()
.unwrap();
let (compound, _) = expect_compound(&output.file.body[0]);
let AstCompoundCommand::Always(command) = compound else {
panic!("expected always compound command");
};
assert_eq!(command.body.len(), 1);
assert_eq!(command.always_body.len(), 1);
assert_eq!(
output.file.body[1].terminator,
Some(StmtTerminator::Background(BackgroundOperator::Pipe))
);
assert_eq!(
output.file.body[2].terminator,
Some(StmtTerminator::Background(BackgroundOperator::Bang))
);
}
#[test]
fn test_parse_malformed_parameter_replacement_fuzz_regression_does_not_panic() {
const INPUT: &[u8] = &[
0x23, 0x21, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x62, 0x61, 0x73, 0x68, 0x0a, 0x0a, 0x23, 0x20,
0x49, 0x6e, 0x76, 0x64, 0x66, 0x22, 0x20, 0x22, 0x64, 0x6f, 0x63, 0x22, 0x29, 0x0a, 0x65,
0x78, 0x74, 0x73, 0x3d, 0x22, 0x24, 0x7b, 0x65, 0x78, 0x74, 0x73, 0x5b, 0x2a, 0x5d, 0x7d,
0x22, 0x0a, 0x65, 0x78, 0x74, 0x73, 0x2b, 0x3d, 0x22, 0x20, 0x24, 0x7b, 0x65, 0x78, 0x74,
0x73, 0x5e, 0x5e, 0x7d, 0x22, 0x0a, 0x65, 0x63, 0xdd, 0x8d, 0x65, 0x75, 0x20, 0x65, 0x61,
0x73, 0x64, 0x20, 0x73, 0x73, 0x63, 0x61, 0x6c, 0x61, 0x72, 0x20, 0x74, 0x65, 0x78, 0x74,
0x40, 0x2e, 0x0a, 0x65, 0x78, 0x74, 0x73, 0x3d, 0x28, 0x5d, 0x74, 0x78, 0x74, 0x22, 0x20,
0x22, 0x70, 0x64, 0x66, 0x22, 0x20, 0x22, 0x64, 0x6f, 0x63, 0x22, 0x29, 0x0a, 0x65, 0x78,
0x74, 0x73, 0x3d, 0x22, 0x24, 0x7b, 0x65, 0x78, 0x74, 0x69, 0x6e, 0x2f, 0x62, 0x61, 0x73,
0x68, 0x0a, 0x0a, 0x23, 0x20, 0x49, 0x6e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x76, 0x64, 0x66, 0x22, 0x20,
0x22, 0x64, 0x6f, 0x63, 0x22, 0x29, 0x0a, 0x65, 0x78, 0x74, 0x73, 0x3d, 0x22, 0x24, 0x7b,
0x65, 0x78, 0x74, 0x73, 0x5b, 0x2a, 0x5d, 0x7d, 0x22, 0x0a, 0x65, 0x78, 0x74, 0x73, 0x2b,
0x3d, 0x22, 0x20, 0x24, 0x7b, 0x65, 0x78, 0x74, 0x73, 0x5e, 0x2f, 0x00, 0x00, 0x00, 0x65,
0x63, 0xdd, 0x8d, 0x65, 0x75, 0x20, 0x65, 0x61, 0x73, 0x64, 0x20, 0x73, 0x73, 0x63, 0x61,
0x6c, 0x61, 0x72, 0x20, 0x74, 0x65, 0x78, 0x74, 0x2e, 0x0a, 0x65, 0x78, 0x74, 0x73, 0x3d,
0x28, 0x5d, 0x74, 0x78, 0x74, 0x22, 0x20, 0x22, 0x70, 0x64, 0x66, 0x22, 0x20, 0x22, 0x64,
0x6f, 0x63, 0x22, 0x29, 0x0a, 0x65, 0x78, 0x74, 0x73, 0x3d, 0x22, 0x24, 0x7b, 0x65, 0x78,
0x74, 0x2f, 0x5b, 0x2a, 0x5d, 0x7d, 0x22, 0x0a, 0x65, 0x78, 0x74, 0x73, 0x2b, 0x3d, 0x31,
0x20, 0x24, 0x7b, 0x65, 0x78, 0x74, 0x73, 0x5e, 0x5e, 0x7d, 0x22, 0x0a, 0x65, 0x63, 0x68,
0x6f, 0x20, 0x22, 0x24, 0x65, 0x78, 0x74, 0x73, 0x22, 0x2a, 0x0a, 0x23, 0x20, 0x56, 0x61,
0x6c, 0x2b, 0x2b, 0x3a, 0x20, 0x66, 0x6c, 0x61, 0x74, 0x74, 0x65, 0x6e, 0x69, 0x6e, 0x5f,
0x00, 0x69, 0x6e, 0x74, 0x6f, 0x2f, 0x5b, 0x2a, 0x5d, 0x7d, 0x22, 0x0a, 0x65, 0x78, 0x74,
0x73, 0x2b, 0x3d, 0x32, 0x20, 0x24, 0x7b, 0x65, 0x78, 0x74, 0x73, 0x5e, 0x5e, 0x7d, 0x22,
0x0a, 0x65, 0x63, 0x68, 0x6f, 0x20, 0x22, 0x24, 0x65, 0x78, 0x74, 0x73, 0x22, 0x2a, 0x0a,
0x23, 0x20, 0x56, 0x61, 0x6c, 0x2b, 0x2b, 0x3a, 0x20, 0x66, 0x6c, 0x61, 0x74, 0x74, 0x65,
0x6e, 0x69, 0x6e, 0x5f, 0x00, 0x69, 0x6e, 0x74, 0x6f, 0x20, 0x61, 0x20, 0x64, 0x4e, 0x66,
0x66, 0x65, 0x72, 0x65, 0x6e, 0x74, 0x20, 0x73,
];
let input = std::str::from_utf8(INPUT).unwrap();
let case_script = format!(
"case \"test.txt\" in {}) echo match;; *) echo no;; esac\n",
input
);
let _ = Parser::new(&case_script).parse();
let conditional_script = format!("if [[ \"hello.world\" == {} ]]; then echo y; fi\n", input);
let _ = Parser::new(&conditional_script).parse();
}
#[test]
fn test_parse_arithmetic_utf8_parameter_replacement_fuzz_regression_does_not_panic() {
const INPUT: &str = "#!/ di[@]}\")\")#!/bin/$x)\narr=($(]x ib) echo#b;;\n#!/bi/nbash\n\narr=${in/bas\nunc(#!${in/sa\nar\0\0\0)\0\0<\0<\0=\u{2018}woin/sa\u{2019}es\0 \"two worac\ntr";
let script = format!("echo $(({}))\n", INPUT);
let _ = Parser::new(&script).parse();
}
#[test]
fn test_parse_glob_utf8_parameter_replacement_fuzz_regression_does_not_panic() {
const INPUT: &str = "'a\0b'\nar=(\n\n{)${!f/e¢}\")\ni";
let case_script = format!(
"case \"test.txt\" in {}) echo match;; *) echo no;; esac\n",
INPUT
);
let _ = Parser::new(&case_script).parse();
let conditional_script = format!("if [[ \"hello.world\" == {} ]]; then echo y; fi\n", INPUT);
let _ = Parser::new(&conditional_script).parse();
}