use super::*;
use shuck_ast::Command as AstCommand;
#[test]
fn test_heredoc_pipe() {
let parser = Parser::new("cat <<EOF | sort\nc\na\nb\nEOF\n");
let script = parser.parse().unwrap().file;
assert!(
matches!(&script.body[0].command, AstCommand::Binary(_)),
"heredoc with pipe should parse as a binary pipe"
);
}
#[test]
fn test_prefix_heredoc_before_command_in_pipeline_parses() {
let input = "<<EOF tac | tr '\\n' 'X'\none\ntwo\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let pipeline = expect_binary(&script.body[0]);
assert_eq!(pipeline.op, BinaryOp::Pipe);
let command = expect_simple(&pipeline.left);
assert_eq!(command.name.render(input), "tac");
assert_eq!(pipeline.left.redirects.len(), 1);
assert_eq!(pipeline.left.redirects[0].kind, RedirectKind::HereDoc);
}
#[test]
fn test_heredoc_with_bare_line_continuation_after_delimiter_still_parses() {
let input = "cat <<EOF \\\n1\n2\n3\nEOF\n| tac\n";
assert!(Parser::new(input).parse().is_ok());
}
#[test]
fn test_heredoc_comment_backslash_after_delimiter_still_parses() {
let input = "cat <<EOF # note \\\nbody\nEOF\n";
assert!(Parser::new(input).parse().is_ok());
}
#[test]
fn test_heredoc_right_paren_comment_backslash_after_delimiter_still_parses() {
let input = "( cat <<EOF )# note \\\nbody\nEOF\n";
assert!(Parser::new(input).parse().is_ok());
}
#[test]
fn test_heredoc_blank_prefix_line_continuation_into_pipe_tail_still_parses() {
let input = "cat <<EOF \\\n| tac\n1\nEOF\n";
assert!(Parser::new(input).parse().is_ok());
}
#[test]
fn test_function_definition_absorbs_trailing_heredoc_redirect() {
let input = "f() { cat; } <<EOF\nhello\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let function = expect_function(&script.body[0]);
let (_, redirects) = expect_compound(function.body.as_ref());
assert!(!function.uses_function_keyword());
assert!(function.has_name_parens());
assert_eq!(redirects.len(), 1);
assert_eq!(redirects[0].kind, RedirectKind::HereDoc);
}
#[test]
fn test_function_body_command_with_heredoc_parses() {
let input = "f() {\n read head << EOF\nref: refs/heads/dev/andy\nEOF\n}\nf\n";
let script = Parser::new(input).parse().unwrap().file;
assert_eq!(script.body.len(), 2);
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_eq!(body.len(), 1);
}
#[test]
fn test_named_fd_heredoc_redirect_keeps_fd_var_metadata() {
let input = "exec {docfd}<<EOF\nhello\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let stmt = &script.body[0];
let command = expect_simple(stmt);
assert_eq!(command.name.render(input), "exec");
assert_eq!(stmt.redirects.len(), 1);
assert_eq!(stmt.redirects[0].kind, RedirectKind::HereDoc);
assert_eq!(stmt.redirects[0].fd_var.as_deref(), Some("docfd"));
assert_eq!(stmt.redirects[0].fd_var_span.unwrap().slice(input), "docfd");
}
#[test]
fn test_named_fd_heredoc_redirect_keeps_fd_var_metadata_across_line_continuation() {
let input = "exec {docfd}\\\n<<EOF\nhello\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let stmt = &script.body[0];
let command = expect_simple(stmt);
assert_eq!(command.name.render(input), "exec");
assert_eq!(stmt.redirects.len(), 1);
assert_eq!(stmt.redirects[0].kind, RedirectKind::HereDoc);
assert_eq!(stmt.redirects[0].fd_var.as_deref(), Some("docfd"));
assert_eq!(stmt.redirects[0].fd_var_span.unwrap().slice(input), "docfd");
}
#[test]
fn test_quoted_word_before_line_continuation_heredoc_stays_a_plain_argument() {
let input = "exec \"{docfd}\"\\\n<<EOF\nhello\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let stmt = &script.body[0];
let command = expect_simple(stmt);
assert_eq!(command.name.render(input), "exec");
assert_eq!(command.args.len(), 1);
assert_eq!(command.args[0].render(input), "{docfd}");
assert_eq!(stmt.redirects.len(), 1);
assert_eq!(stmt.redirects[0].kind, RedirectKind::HereDoc);
assert_eq!(stmt.redirects[0].fd_var.as_deref(), None);
assert_eq!(stmt.redirects[0].fd_var_span, None);
}
#[test]
fn test_spaced_word_before_heredoc_stays_a_plain_argument() {
let input = "echo {docfd} <<EOF\nhello\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let stmt = &script.body[0];
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), "{docfd}");
assert_eq!(stmt.redirects.len(), 1);
assert_eq!(stmt.redirects[0].kind, RedirectKind::HereDoc);
assert_eq!(stmt.redirects[0].fd_var.as_deref(), None);
assert_eq!(stmt.redirects[0].fd_var_span, None);
}
#[test]
fn test_spaced_word_before_output_redirect_stays_a_plain_argument() {
let input = "echo {docfd} >/tmp/out\n";
let script = Parser::new(input).parse().unwrap().file;
let stmt = &script.body[0];
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), "{docfd}");
assert_eq!(stmt.redirects.len(), 1);
assert_eq!(stmt.redirects[0].kind, RedirectKind::Output);
assert_eq!(stmt.redirects[0].fd_var.as_deref(), None);
assert_eq!(stmt.redirects[0].fd_var_span, None);
}
#[test]
fn test_dynamic_heredoc_delimiter_is_rejected() {
let parser = Parser::new("cat <<\"$@\"\nbody\n$@\n");
assert!(
parser.parse().is_err(),
"dynamic heredoc delimiter should fail"
);
}
#[test]
fn test_non_static_heredoc_delimiter_forms_are_rejected() {
let cases = [
("short parameter", "cat <<$bar\n"),
("brace parameter", "cat <<${bar}\n"),
("command substitution", "cat <<$(bar)\n"),
("backquoted command substitution", "cat <<`bar`\n"),
("arithmetic expansion", "cat <<$((1 + 2))\n"),
("special parameter", "cat <<$-\n"),
("quoted parameter expansion", "cat <<\"$bar\"\n"),
];
for (name, input) in cases {
let error = Parser::new(input).parse().unwrap_err();
let Error::Parse { message, .. } = error;
assert_eq!(
message, "expected static heredoc delimiter",
"{name} should fail via the static-delimiter check"
);
}
}
#[test]
fn test_heredoc_multiple_on_line() {
let input = "while cat <<E1 && cat <<E2; do cat <<E3; break; done\n1\nE1\n2\nE2\n3\nE3\n";
let parser = Parser::new(input);
let script = parser.parse().unwrap().file;
assert_eq!(script.body.len(), 1);
let (compound, _) = expect_compound(&script.body[0]);
if let AstCompoundCommand::While(w) = compound {
assert!(
!w.condition.is_empty(),
"while condition should be non-empty"
);
assert!(!w.body.is_empty(), "while body should be non-empty");
} else {
panic!("expected While compound command");
}
}
#[test]
fn test_heredoc_multiple_lines_preserve_while_do_boundary() {
let input = "while cat <<E1 && cat <<E2\n1\nE1\n2\nE2\ndo\n cat <<E3\n3\nE3\n break\ndone\n";
let script = Parser::new(input).parse().unwrap().file;
let (compound, redirects) = expect_compound(&script.body[0]);
assert!(redirects.is_empty());
let AstCompoundCommand::While(command) = compound else {
panic!("expected while command");
};
assert_eq!(command.condition.len(), 1);
assert_eq!(command.body.len(), 2);
}
#[test]
fn test_heredoc_target_preserves_body_span() {
let input = "cat <<'EOF'\nhello $name\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let stmt = &script.body[0];
let _command = expect_simple(stmt);
assert_eq!(stmt.redirects.len(), 1);
let redirect = &stmt.redirects[0];
let heredoc = redirect_heredoc(redirect);
assert_eq!(heredoc.body.span.slice(input), "hello $name\n");
assert!(heredoc_body_is_literal(&heredoc.body));
}
#[test]
fn test_heredoc_delimiter_metadata_tracks_flags_and_spans() {
let input = "cat <<EOF\nhello\nEOF\ncat <<'EOF'\nhello\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let unquoted_stmt = &script.body[0];
let _unquoted = expect_simple(unquoted_stmt);
let unquoted_redirect = &unquoted_stmt.redirects[0];
let unquoted_heredoc = redirect_heredoc(unquoted_redirect);
assert_eq!(unquoted_redirect.span.slice(input), "<<EOF");
assert_eq!(unquoted_heredoc.delimiter.span.slice(input), "EOF");
assert_eq!(unquoted_heredoc.delimiter.raw.span.slice(input), "EOF");
assert_eq!(unquoted_heredoc.delimiter.cooked, "EOF");
assert!(!unquoted_heredoc.delimiter.quoted);
assert!(unquoted_heredoc.delimiter.expands_body);
assert!(!unquoted_heredoc.delimiter.strip_tabs);
let quoted_stmt = &script.body[1];
let _quoted = expect_simple(quoted_stmt);
let quoted_redirect = "ed_stmt.redirects[0];
let quoted_heredoc = redirect_heredoc(quoted_redirect);
assert_eq!(quoted_redirect.span.slice(input), "<<'EOF'");
assert_eq!(quoted_heredoc.delimiter.span.slice(input), "'EOF'");
assert_eq!(quoted_heredoc.delimiter.raw.span.slice(input), "'EOF'");
assert_eq!(quoted_heredoc.delimiter.cooked, "EOF");
assert!(quoted_heredoc.delimiter.quoted);
assert!(!quoted_heredoc.delimiter.expands_body);
assert!(!quoted_heredoc.delimiter.strip_tabs);
}
#[test]
fn test_heredoc_delimiter_preserves_mixed_quoted_raw_and_cooked_value() {
let input = "cat <<'EOF'\"2\"\nbody\nEOF2\n";
let script = Parser::new(input).parse().unwrap().file;
let stmt = &script.body[0];
let _command = expect_simple(stmt);
let redirect = &stmt.redirects[0];
let heredoc = redirect_heredoc(redirect);
assert_eq!(redirect.span.slice(input), "<<'EOF'\"2\"");
assert_eq!(heredoc.delimiter.raw.span.slice(input), "'EOF'\"2\"");
assert_eq!(heredoc.delimiter.cooked, "EOF2");
assert!(heredoc.delimiter.quoted);
assert!(!heredoc.delimiter.expands_body);
}
#[test]
fn tab_stripped_heredoc_nested_command_substitution_keeps_inner_span_source_backed() {
let input = "\
cat <<-EOF > \"${name}\"
\t#!/bin/sh
\ttest \"\\$#\" -ge 1 || exit 1
\techo $(eval echo \\$$(echo cfgtest_${name})) | tr ' ' '\\n' > \\$1
EOF
";
let script = Parser::new(input).parse().unwrap().file;
let stmt = &script.body[0];
let redirect = &stmt.redirects[0];
let heredoc = redirect_heredoc(redirect);
let outer = heredoc
.body
.parts
.iter()
.find_map(|part| match &part.kind {
HeredocBodyPart::CommandSubstitution { body, .. } => Some((part.span, body)),
_ => None,
})
.expect("expected outer heredoc command substitution");
let inner = outer
.1
.stmts
.iter()
.find_map(|stmt| match &stmt.command {
AstCommand::Simple(command) => command
.args
.iter()
.flat_map(|word| word.parts.iter())
.find(|part| matches!(part.kind, WordPart::CommandSubstitution { .. })),
_ => None,
})
.expect("expected inner command substitution");
assert_eq!(
outer.0.slice(input),
"$(eval echo \\$$(echo cfgtest_${name}))"
);
assert_eq!(inner.span.slice(input), "$(echo cfgtest_${name})");
assert_eq!(inner.span.start.line, 4);
assert_eq!(inner.span.start.column, 21);
assert_eq!(inner.span.end.column, 44);
}
#[test]
fn test_backslash_escaped_heredoc_delimiter_is_treated_as_quoted_static_text() {
let input = "cat <<\\EOF\nhello $name\nEOF\n";
for dialect in [ShellDialect::Bash, ShellDialect::Posix, ShellDialect::Mksh] {
let script = Parser::with_dialect(input, dialect).parse().unwrap().file;
let stmt = &script.body[0];
let _command = expect_simple(stmt);
let redirect = &stmt.redirects[0];
let heredoc = redirect_heredoc(redirect);
assert_eq!(redirect.span.slice(input), "<<\\EOF");
assert_eq!(heredoc.delimiter.span.slice(input), "\\EOF");
assert_eq!(heredoc.delimiter.raw.span.slice(input), "\\EOF");
assert_eq!(heredoc.delimiter.cooked, "EOF", "dialect: {dialect:?}");
assert!(heredoc.delimiter.quoted, "dialect: {dialect:?}");
assert!(!heredoc.delimiter.expands_body, "dialect: {dialect:?}");
assert!(!heredoc.delimiter.strip_tabs, "dialect: {dialect:?}");
assert!(
heredoc_body_is_literal(&heredoc.body),
"dialect: {dialect:?}"
);
assert_eq!(heredoc.body.render(input), "hello $name\n");
}
}
#[test]
fn test_backslash_escaped_heredoc_with_trailing_redirect_keeps_following_command() {
let input = "cat <<\\EOF >&2\nUsage:\nEOF\nexit 255\n";
for dialect in [ShellDialect::Bash, ShellDialect::Posix, ShellDialect::Mksh] {
let script = Parser::with_dialect(input, dialect).parse().unwrap().file;
assert_eq!(script.body.len(), 2, "dialect: {dialect:?}");
let stmt = &script.body[0];
let _command = expect_simple(stmt);
assert_eq!(stmt.redirects.len(), 2, "dialect: {dialect:?}");
let heredoc = redirect_heredoc(&stmt.redirects[0]);
assert_eq!(heredoc.delimiter.cooked, "EOF", "dialect: {dialect:?}");
assert!(heredoc.delimiter.quoted, "dialect: {dialect:?}");
assert_eq!(
heredoc.body.render(input),
"Usage:\n",
"dialect: {dialect:?}"
);
let AstCommand::Builtin(shuck_ast::BuiltinCommand::Exit(exit)) = &script.body[1].command
else {
panic!("expected exit builtin, dialect: {dialect:?}");
};
let code = exit.code.as_ref().expect("expected exit code");
assert_eq!(code.render(input), "255", "dialect: {dialect:?}");
}
}
#[test]
fn test_heredoc_closer_with_trailing_whitespace_still_closes() {
let input = "cat <<EOF\nbody\nEOF \nnext\n";
for dialect in [ShellDialect::Bash, ShellDialect::Posix, ShellDialect::Mksh] {
let script = Parser::with_dialect(input, dialect).parse().unwrap().file;
assert_eq!(script.body.len(), 2, "dialect: {dialect:?}");
let stmt = &script.body[0];
let _command = expect_simple(stmt);
let heredoc = redirect_heredoc(&stmt.redirects[0]);
assert_eq!(heredoc.body.render(input), "body\n", "dialect: {dialect:?}");
let next = expect_simple(&script.body[1]);
assert_eq!(next.name.render(input), "next", "dialect: {dialect:?}");
}
}
#[test]
fn test_backslash_escaped_heredoc_inside_command_substitution_stays_quoted_in_posix() {
let input = "\
build=\"$(command cat <<\\EOF
hello $name
EOF
)\"
";
let script = Parser::with_dialect(input, ShellDialect::Posix)
.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 double-quoted assignment value");
};
let WordPart::CommandSubstitution { body, .. } = &parts[0].kind else {
panic!("expected command substitution");
};
let command = expect_simple(&body[0]);
assert_eq!(
command.args.len(),
1,
"args: {:?}",
command
.args
.iter()
.map(|word| word.render(input))
.collect::<Vec<_>>()
);
assert_eq!(command.args[0].render(input), "cat");
assert_eq!(body[0].redirects.len(), 1);
let heredoc = redirect_heredoc(&body[0].redirects[0]);
assert!(heredoc.delimiter.quoted);
assert!(!heredoc.delimiter.expands_body);
assert!(heredoc_body_is_literal(&heredoc.body));
}
#[test]
fn test_posix_quoted_heredoc_in_command_substitution_does_not_leak_body_statements() {
let input = "\
build=\"$(command cat <<\\END
outdir=\"$(command pwd)\"
if command -v mktemp >/dev/null 2>&1; then
workdir=\"$(command mktemp -d \"${TMPDIR:-/tmp}\"/gitstatus-build.XXXXXXXXXX)\"
else
workdir=\"${TMPDIR:-/tmp}/gitstatus-build.tmp.$$\"
command mkdir -- \"$workdir\"
fi
if [ -n \"$gitstatus_install_tools\" ]; then
case \"$gitstatus_kernel\" in
darwin)
if command -v port >/dev/null 2>&1; then
sudo port -N install libiconv cmake wget
elif command -v brew >/dev/null 2>&1; then
for formula in libiconv cmake git wget; do
if command brew ls --version \"$formula\" &>/dev/null; then
command brew upgrade \"$formula\"
else
command brew install \"$formula\"
fi
done
fi
;;
esac
fi
case \"$gitstatus_cpu\" in
powerpc64|powerpc64le)
archflag=\"-mcpu\"
;;
*)
archflag=\"-march\"
;;
esac
case \"$gitstatus_arch\" in
e2k)
nopltflag=\"\"
;;
*)
nopltflag=\"-fno-plt\"
;;
esac
cflags=\"$archflag=$gitstatus_cpu $nopltflag -D_FORTIFY_SOURCE=2 -Wformat -Werror=format-security -fpie\"
END
)\"
";
let script = Parser::with_dialect(input, ShellDialect::Posix)
.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 double-quoted assignment value");
};
assert_eq!(parts.len(), 1, "parts: {parts:#?}");
let WordPart::CommandSubstitution { body, .. } = &parts[0].kind else {
panic!("expected command substitution");
};
assert_eq!(body.len(), 1, "body: {body:#?}");
let _command = expect_simple(&body[0]);
let heredoc = redirect_heredoc(&body[0].redirects[0]);
assert!(heredoc.delimiter.quoted);
assert!(!heredoc.delimiter.expands_body);
assert!(heredoc_body_is_literal(&heredoc.body));
}
#[test]
fn test_bash_quoted_heredoc_in_command_substitution_does_not_leak_body_statements() {
let input = "\
build=\"$(command cat <<\\END
outdir=\"$(command pwd)\"
if command -v mktemp >/dev/null 2>&1; then
workdir=\"$(command mktemp -d \"${TMPDIR:-/tmp}\"/gitstatus-build.XXXXXXXXXX)\"
else
workdir=\"${TMPDIR:-/tmp}/gitstatus-build.tmp.$$\"
command mkdir -- \"$workdir\"
fi
if [ -n \"$gitstatus_install_tools\" ]; then
case \"$gitstatus_kernel\" in
darwin)
if command -v port >/dev/null 2>&1; then
sudo port -N install libiconv cmake wget
elif command -v brew >/dev/null 2>&1; then
for formula in libiconv cmake git wget; do
if command brew ls --version \"$formula\" &>/dev/null; then
command brew upgrade \"$formula\"
else
command brew install \"$formula\"
fi
done
fi
;;
esac
fi
case \"$gitstatus_cpu\" in
powerpc64|powerpc64le)
archflag=\"-mcpu\"
;;
*)
archflag=\"-march\"
;;
esac
case \"$gitstatus_arch\" in
e2k)
nopltflag=\"\"
;;
*)
nopltflag=\"-fno-plt\"
;;
esac
cflags=\"$archflag=$gitstatus_cpu $nopltflag -D_FORTIFY_SOURCE=2 -Wformat -Werror=format-security -fpie\"
command cat >&2 <<-END
\tSUCCESS
\tEND
END
)\"
";
let script = Parser::with_dialect(input, ShellDialect::Bash)
.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 double-quoted assignment value");
};
let WordPart::CommandSubstitution { body, .. } = &parts[0].kind else {
panic!("expected command substitution");
};
assert_eq!(body.len(), 1, "body: {body:#?}");
let _command = expect_simple(&body[0]);
let heredoc = redirect_heredoc(&body[0].redirects[0]);
assert!(heredoc.delimiter.quoted);
assert!(!heredoc.delimiter.expands_body);
assert!(heredoc_body_is_literal(&heredoc.body));
}
#[test]
fn test_heredoc_strip_tabs_sets_delimiter_metadata() {
let input = "cat <<-EOF\n\t$NAME\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let stmt = &script.body[0];
let _command = expect_simple(stmt);
let redirect = &stmt.redirects[0];
let heredoc = redirect_heredoc(redirect);
assert_eq!(redirect.span.slice(input), "<<-EOF");
assert!(heredoc.delimiter.strip_tabs);
assert!(heredoc.delimiter.expands_body);
assert_eq!(heredoc.delimiter.cooked, "EOF");
}
#[test]
fn test_heredoc_targets_preserve_quoted_and_unquoted_decode_behavior() {
let input = "cat <<EOF\nhello $name\nEOF\ncat <<'EOF'\nhello $name\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let unquoted_target = &redirect_heredoc(&script.body[0].redirects[0]).body;
assert!(!heredoc_body_is_literal(unquoted_target));
assert_eq!(unquoted_target.render(input), "hello $name\n");
let unquoted_slices = heredoc_top_level_part_slices(unquoted_target, input);
assert_eq!(unquoted_slices, vec!["hello ", "$name", "\n"]);
assert!(matches!(
unquoted_target.parts[1].kind,
shuck_ast::HeredocBodyPart::Variable(_)
));
let quoted_target = &redirect_heredoc(&script.body[1].redirects[0]).body;
assert!(heredoc_body_is_literal(quoted_target));
assert_eq!(quoted_target.render(input), "hello $name\n");
assert!(matches!(
quoted_target.parts.as_slice(),
[part] if matches!(&part.kind, shuck_ast::HeredocBodyPart::Literal(_))
));
}
#[test]
fn test_unquoted_heredoc_body_preserves_multiple_quoted_fragments() {
let input = "cat <<EOF\nbefore '$HOME' and \"$USER\"\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let body = &script.body[0]
.redirects
.iter()
.find_map(|redirect| redirect.heredoc())
.expect("expected heredoc redirect")
.body;
assert!(!heredoc_body_is_literal(body));
assert_eq!(
heredoc_top_level_part_slices(body, input),
vec!["before '", "$HOME", "' and \"", "$USER", "\"\n"]
);
assert!(matches!(
body.parts[1].kind,
shuck_ast::HeredocBodyPart::Variable(_)
));
assert!(matches!(
body.parts[3].kind,
shuck_ast::HeredocBodyPart::Variable(_)
));
}
#[test]
fn test_unquoted_heredoc_body_keeps_dollar_quoted_forms_literal() {
let input = "cat <<EOF\n$'line\\n' $\"hello\" ${name}\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let body = &script.body[0]
.redirects
.iter()
.find_map(|redirect| redirect.heredoc())
.expect("expected heredoc redirect")
.body;
assert_eq!(
heredoc_top_level_part_slices(body, input),
vec!["$'line\\n' ", "$\"hello\" ", "${name}", "\n"]
);
assert!(matches!(
body.parts[0].kind,
shuck_ast::HeredocBodyPart::Literal(_)
));
assert!(matches!(
body.parts[1].kind,
shuck_ast::HeredocBodyPart::Literal(_)
));
assert!(matches!(
body.parts[2].kind,
shuck_ast::HeredocBodyPart::Parameter(_)
));
}
#[test]
fn test_unquoted_heredoc_body_keeps_later_expansions_live_after_quoted_lines() {
let input = "\
cat <<EOF > \"$archname\"
#!/bin/sh
ORIG_UMASK=`umask`
if test \"$KEEP_UMASK\" = n; then
umask 077
fi
CRCsum=\"$CRCsum\"
archdirname=\"$archdirname\"
EOF
";
let script = Parser::new(input).parse().unwrap().file;
let body = &script.body[0]
.redirects
.iter()
.find_map(|redirect| redirect.heredoc())
.expect("expected heredoc redirect")
.body;
let slices = heredoc_top_level_part_slices(body, input);
assert!(
body.parts.iter().any(|part| {
matches!(
&part.kind,
shuck_ast::HeredocBodyPart::Variable(name) if name.as_str() == "CRCsum"
)
}),
"expected heredoc body to keep $CRCsum live: {slices:?}"
);
assert!(
body.parts.iter().any(|part| {
matches!(
&part.kind,
shuck_ast::HeredocBodyPart::Variable(name) if name.as_str() == "archdirname"
)
}),
"expected heredoc body to keep $archdirname live: {slices:?}"
);
}
#[test]
fn test_unquoted_heredoc_body_leaves_unmatched_single_quote_literal() {
let input = "cat <<EOF\n'$HOME\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let body = &redirect_heredoc(&script.body[0].redirects[0]).body;
assert!(
!body
.parts
.iter()
.any(|part| matches!(part.kind, shuck_ast::HeredocBodyPart::Parameter(_)))
);
assert_eq!(body.render_syntax(input), "'$HOME\n");
}
#[test]
fn test_strip_tabs_heredoc_body_preserves_single_quoted_fragments() {
let input = "cat <<-EOF\n\t'$HOME'\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let heredoc = redirect_heredoc(&script.body[0].redirects[0]);
assert!(heredoc.delimiter.strip_tabs);
assert_eq!(heredoc.body.parts.len(), 3);
assert!(matches!(
heredoc.body.parts[0].kind,
shuck_ast::HeredocBodyPart::Literal(_)
));
assert!(matches!(
heredoc.body.parts[1].kind,
shuck_ast::HeredocBodyPart::Variable(_)
));
assert!(matches!(
heredoc.body.parts[2].kind,
shuck_ast::HeredocBodyPart::Literal(_)
));
assert_eq!(heredoc.body.render_syntax(input), "'$HOME'\n");
}
#[test]
fn test_strip_tabs_heredoc_command_substitution_keeps_nested_command_spans() {
let input = "\
case \"${tag_type}\" in
\t*)
\t\ttermux_error_exit <<-EndOfError
\t\t\tERROR: Invalid TERMUX_PKG_UPDATE_TAG_TYPE: '${tag_type}'.
\t\t\tAllowed values: 'newest-tag', 'latest-release-tag', 'latest-regex'.
\t\tEndOfError
\t;;
esac
case \"${http_code}\" in
\t404)
\t\ttermux_error_exit <<-EndOfError
\t\t\tNo '${tag_type}' found. (${api_url})
\t\t\tHTTP code: ${http_code}
\t\t\tTry using '$(
\t\t\t\tif [[ \"${tag_type}\" == \"newest-tag\" ]]; then
\t\t\t\t\techo \"latest-release-tag\"
\t\t\t\telse
\t\t\t\t\techo \"newest-tag\"
\t\t\t\tfi
\t\t\t)'.
\t\tEndOfError
\t;;
esac
";
let script = Parser::new(input).parse().unwrap().file;
let (compound, _) = expect_compound(&script.body[1]);
let AstCompoundCommand::Case(case) = compound else {
panic!("expected case command");
};
let command = expect_simple(&case.cases[0].body[0]);
let heredoc = redirect_heredoc(&case.cases[0].body[0].redirects[0]);
assert_eq!(command.name.render(input), "termux_error_exit");
let command_substitution = heredoc
.body
.parts
.iter()
.find_map(|part| match &part.kind {
shuck_ast::HeredocBodyPart::CommandSubstitution { body, .. } => Some(body),
_ => None,
})
.expect("expected command substitution inside heredoc");
let (if_compound, _) = expect_compound(&command_substitution[0]);
let AstCompoundCommand::If(if_command) = if_compound else {
panic!("expected if command inside command substitution");
};
let AstCommand::Compound(AstCompoundCommand::Conditional(conditional)) =
&if_command.condition[0].command
else {
panic!("expected conditional command in if header");
};
assert_eq!(
conditional.span.slice(input),
"[[ \"${tag_type}\" == \"newest-tag\" ]]"
);
assert_eq!(
expect_simple(&if_command.then_branch[0]).span.slice(input),
"echo \"latest-release-tag\"\n"
);
assert_eq!(
expect_simple(&if_command.else_branch.as_ref().unwrap()[0])
.span
.slice(input),
"echo \"newest-tag\"\n"
);
}
#[test]
fn test_strip_tabs_heredoc_body_keeps_parameter_part_source_spans() {
let input = "cat <<-EOF\n\tExpected: ${TERMUX_PKG_VERSION//\\~/-}\nEOF\n";
let script = Parser::new(input).parse().unwrap().file;
let heredoc = redirect_heredoc(&script.body[0].redirects[0]);
let parameter = heredoc
.body
.parts
.iter()
.find(|part| matches!(part.kind, shuck_ast::HeredocBodyPart::Parameter(_)))
.expect("expected parameter part");
assert_eq!(
parameter.span.slice(input).trim_end_matches('\n'),
"${TERMUX_PKG_VERSION//\\~/-}"
);
}
#[test]
fn test_comment_ranges_heredoc_no_false_comments() {
let source = "cat <<EOF\n# not a comment\nline two\nEOF\n# real\n";
let output = Parser::new(source).parse().unwrap();
assert_comment_ranges_valid(source, &output);
let texts: Vec<&str> = collect_file_comments(&output.file)
.iter()
.map(|c| c.range.slice(source))
.collect();
assert!(
!texts.iter().any(|t| t.contains("not a comment")),
"heredoc body produced a false comment: {texts:?}"
);
}
#[test]
fn test_comment_ranges_heredoc_with_unicode() {
let source = "cat <<EOF\n# 你好\ncafé\nEOF\n# end\n";
let output = Parser::new(source).parse().unwrap();
assert_comment_ranges_valid(source, &output);
}
#[test]
fn test_comment_ranges_heredoc_desktop_entry() {
let source = r#"cat << EOF > "${HOME}/test.desktop"
[Desktop Entry]
Name=${entry_name}
GenericName=Terminal entering ${entry_name}
Comment=Terminal entering ${entry_name}
Categories=Distrobox;System;Utility
Exec=${distrobox_path}/distrobox enter ${extra_flags} ${container_name}
Icon=${icon}
Terminal=true
Type=Application
EOF
# done
"#;
let output = Parser::new(source).parse().unwrap();
assert_comment_ranges_valid(source, &output);
let texts: Vec<&str> = collect_file_comments(&output.file)
.iter()
.map(|c| c.range.slice(source))
.collect();
for text in &texts {
assert!(
!text.contains("Desktop") && !text.contains("entry_name"),
"heredoc body leaked as comment: {text:?}"
);
}
}