use super::bash_tokens::{RedirectRole, redirect_role, tokenize};
use super::heredoc::{HeredocBodies, split_heredoc_bodies};
use super::{
MAX_SUBSTITUTION_DEPTH, scan_file_write_redirects, shell_lex, split_shell_segments_raw,
};
use crate::commands::hook_rewrite::{first_command_token, strip_wrapper_prefix};
pub(crate) type UnplaceableWrite = &'static str;
const UNBALANCED_BODY: UnplaceableWrite =
"a subshell `(`, command substitution `$(`/backtick or process substitution that never closes";
const TOO_DEEP: UnplaceableWrite = "substitutions or wrappers nested deeper than the guard follows";
const UNLEXABLE_TEE: UnplaceableWrite = "a `tee` whose arguments do not lex";
const UNLEXABLE_WRAPPER: UnplaceableWrite =
"an `sh -c`/`bash -c`/`env -S`/`xargs` wrapper whose inner command does not lex";
pub(crate) fn shell_write_targets(command: &str) -> Result<Vec<String>, UnplaceableWrite> {
shell_segments_map(command, &segment_write_targets)
}
pub(crate) fn shell_segments_map<T>(
command: &str,
per_segment: &dyn Fn(&str) -> Result<Vec<T>, UnplaceableWrite>,
) -> Result<Vec<T>, UnplaceableWrite> {
targets_at(command, 0, per_segment)
}
fn targets_at<T>(
command: &str,
depth: usize,
per_segment: &dyn Fn(&str) -> Result<Vec<T>, UnplaceableWrite>,
) -> Result<Vec<T>, UnplaceableWrite> {
if depth > MAX_SUBSTITUTION_DEPTH {
return Err(TOO_DEEP);
}
let (outer, bodies) = lift_bodies(command)?;
let mut out = Vec::new();
for raw in split_shell_segments_raw(&outer) {
let segment = raw.trim();
out.extend(per_segment(segment)?);
match shell_lex::wrapped_command(segment) {
shell_lex::WrappedCommand::Inner(inner) => {
out.extend(targets_at(&inner, depth + 1, per_segment)?);
}
shell_lex::WrappedCommand::Unlexable => return Err(UNLEXABLE_WRAPPER),
shell_lex::WrappedCommand::None => {}
}
}
for body in bodies {
out.extend(targets_at(&body, depth + 1, per_segment)?);
}
Ok(out)
}
pub(super) fn segment_write_targets(segment: &str) -> Result<Vec<String>, UnplaceableWrite> {
if segment.is_empty() {
return Ok(Vec::new());
}
let mut out: Vec<String> = scan_file_write_redirects(segment)
.into_iter()
.filter(|target| !target.is_empty() && !target.starts_with('('))
.collect();
if first_command_token(segment).as_deref() == Some("git")
&& let Some(target) = shell_lex::git_file_write_target(segment)
&& !target.is_empty()
{
out.push(target);
}
out.extend(tee_targets(segment)?);
Ok(out)
}
fn tee_targets(segment: &str) -> Result<Vec<String>, UnplaceableWrite> {
let argv = match tokenize(&split_heredoc_bodies(segment).0) {
Ok(argv) => argv,
Err(_) if first_command_token(segment).as_deref() == Some("tee") => {
return Err(UNLEXABLE_TEE);
}
Err(_) => return Ok(Vec::new()),
};
let is_tee = |word: &String| basename(word) == "tee";
let program = strip_wrapper_prefix(&argv).or_else(|| argv.iter().position(is_tee));
let Some(program) = program.filter(|&at| argv.get(at).is_some_and(is_tee)) else {
return Ok(Vec::new());
};
let mut out = Vec::new();
let mut options = true;
let mut skip_next = false;
for word in &argv[program + 1..] {
if std::mem::take(&mut skip_next) {
continue;
}
if options && word == "--" {
options = false;
continue;
}
if options && word.starts_with('-') && word.len() > 1 {
continue;
}
match redirect_role(word) {
RedirectRole::TargetFollows => skip_next = true,
RedirectRole::Target(_) | RedirectRole::FileDescriptor => {}
RedirectRole::None => match input_redirect(word) {
Some(target_follows) => skip_next = target_follows,
None => out.push(before_unmatched_close(word).to_string()),
},
}
}
out.retain(|target| !target.is_empty());
Ok(out)
}
fn basename(word: &str) -> &str {
let word = word.trim_start_matches(['$', '(', '`']);
let word = word.strip_prefix('\\').unwrap_or(word);
word.rsplit('/').next().unwrap_or(word)
}
fn before_unmatched_close(word: &str) -> &str {
let mut depth = 0usize;
for (at, c) in word.char_indices() {
match c {
'(' => depth += 1,
')' if depth == 0 => return &word[..at],
')' => depth -= 1,
_ => {}
}
}
word
}
pub(super) fn input_redirect(word: &str) -> Option<bool> {
let after = word
.trim_start_matches(|c: char| c.is_ascii_digit())
.strip_prefix('<')?;
Some(after.trim_start_matches(['<', '-']).is_empty())
}
fn lift_bodies(command: &str) -> Result<(String, Vec<String>), UnplaceableWrite> {
match lift(command, true) {
Lifted::Done(outer, bodies) => Ok((outer, bodies)),
Lifted::Unbalanced => Err(UNBALANCED_BODY),
Lifted::OpenQuote => match lift(command, false) {
Lifted::Done(outer, bodies) => Ok((outer, bodies)),
Lifted::Unbalanced | Lifted::OpenQuote => Err(UNBALANCED_BODY),
},
}
}
enum Lifted {
Done(String, Vec<String>),
Unbalanced,
OpenQuote,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Quote {
None,
Single,
Double,
}
fn lift(command: &str, quotes: bool) -> Lifted {
let heredocs = HeredocBodies::scan(command);
let bytes = command.as_bytes();
let (mut outer, mut bodies) = (String::with_capacity(command.len()), Vec::new());
let (mut quote, mut copied, mut i) = (Quote::None, 0, 0);
while i < bytes.len() {
if heredocs.contains(i) {
if let Some(end) = heredocs.shell_body_starting_at(i) {
outer.push_str(&command[copied..i]);
bodies.push(command[i..end].to_string());
copied = end;
i = end;
continue;
}
i += 1;
continue;
}
let b = bytes[i];
if quote == Quote::Single {
quote = if b == b'\'' { Quote::None } else { quote };
i += 1;
continue;
}
if b == b'\\' {
i += 2;
continue;
}
if b == b'#' && quote == Quote::None && starts_a_word(bytes, i) {
i = command[i..].find('\n').map_or(bytes.len(), |n| i + n);
continue;
}
if quotes && b == b'\'' && quote == Quote::None {
quote = Quote::Single;
} else if quotes && b == b'"' {
quote = if quote == Quote::Double {
Quote::None
} else {
Quote::Double
};
}
let Some(len) = opener_at(bytes, i, quote) else {
i += 1;
continue;
};
match close_of(bytes, i + len, bytes[i + len - 1] == b'`', quotes) {
Some(close) => {
outer.push_str(&command[copied..i + len]);
bodies.push(command[i + len..close].to_string());
copied = close;
i = close + 1;
}
None if quotes => return Lifted::Unbalanced,
None => i += len,
}
}
if quote != Quote::None {
return Lifted::OpenQuote;
}
outer.push_str(&command[copied..]);
Lifted::Done(outer, bodies)
}
fn starts_a_word(bytes: &[u8], i: usize) -> bool {
i == 0
|| matches!(
bytes[i - 1],
b' ' | b'\t' | b'\n' | b';' | b'&' | b'|' | b'('
)
}
fn opener_at(bytes: &[u8], i: usize, quote: Quote) -> Option<usize> {
let next_is_paren = bytes.get(i + 1) == Some(&b'(');
match bytes[i] {
b'$' if next_is_paren => Some(2),
b'`' => Some(1),
b'<' | b'>' if next_is_paren && quote == Quote::None => Some(2),
b'(' if quote == Quote::None => Some(1),
_ => None,
}
}
fn close_of(bytes: &[u8], start: usize, backtick: bool, quotes: bool) -> Option<usize> {
let (mut depth, mut quote, mut j) = (1usize, Quote::None, start);
while j < bytes.len() {
let b = bytes[j];
match (quote, b) {
(Quote::Single, b'\'') => quote = Quote::None,
(Quote::Single, _) => {}
(_, b'\\') => j += 1,
(_, b'`') if backtick => return Some(j),
(Quote::None, b'\'') if quotes => quote = Quote::Single,
(Quote::None, b'"') if quotes => quote = Quote::Double,
(Quote::Double, b'"') => quote = Quote::None,
(Quote::None, b'(') if !backtick => depth += 1,
(Quote::None, b')') if !backtick => {
depth -= 1;
if depth == 0 {
return Some(j);
}
}
_ => {}
}
j += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn targets(command: &str) -> Vec<String> {
shell_write_targets(command).unwrap_or_else(|e| panic!("`{command}`: {e}"))
}
#[test]
fn write_targets_read_tee_substitution_and_subshell_writes() {
for command in [
"tee src/lib.rs",
"echo x | tee src/lib.rs",
"echo x | tee -a src/lib.rs",
"echo x | tee -- src/lib.rs",
"echo x | sudo tee src/lib.rs",
"echo x | sudo -u root tee src/lib.rs",
"echo x | tee src/lib.rs > /dev/null",
"echo x | t'e'e src/lib.rs",
"echo $(echo x > src/lib.rs)",
"echo \"$(echo x > src/lib.rs)\"",
"echo `echo x > src/lib.rs`",
"echo $(echo x | tee src/lib.rs)",
"echo $(cd /tmp; echo x > src/lib.rs)",
"(echo x > src/lib.rs)",
"( cd /tmp && echo x > src/lib.rs )",
"(echo x | tee src/lib.rs)",
"echo x > >(tee src/lib.rs)",
"sh -c 'echo $(echo x > src/lib.rs)'",
"echo $(echo $(echo x > src/lib.rs))",
] {
assert!(
targets(command).contains(&"src/lib.rs".to_string()),
"`{command}` -> {:?}",
targets(command)
);
}
assert_eq!(targets("tee a.md b.rs"), vec!["a.md", "b.rs"]);
assert_eq!(targets("tee -a a.md < in.txt"), vec!["a.md"]);
}
#[test]
fn write_targets_refuse_what_they_cannot_place() {
for command in [
"echo $(echo x > src/lib.rs",
"(echo x > src/lib.rs",
"echo `echo x > src/lib.rs",
"echo x | tee 'src/lib.rs",
] {
assert!(shell_write_targets(command).is_err(), "{command}");
}
let deep = format!("{}echo x > src/lib.rs{}", "$(".repeat(40), ")".repeat(40));
assert_eq!(shell_write_targets(&deep), Err(TOO_DEEP));
}
#[test]
fn write_targets_leave_literal_text_alone() {
for command in [
"echo '$(echo x > src/lib.rs)'",
"echo '(tee src/lib.rs)'",
"cat > notes.md <<'EOF'\nrun $(echo x > src/lib.rs) or (tee src/lib.rs)\nEOF",
"cat > notes.md <<'EOF'\nit's (open and $(unclosed\nEOF",
"echo $((1 + 2)) > n.txt",
"grep -rn tee src/",
"git log --format='(%h)'",
"ls # see (1, it's",
"tee notes.md <<'EOF'\nsrc/lib.rs\nEOF",
] {
let got = targets(command);
assert!(
!got.iter().any(|t| t.contains("lib.rs")),
"`{command}` -> {got:?}"
);
}
}
#[test]
fn write_targets_read_a_clobber_redirect() {
for command in [
"echo x >|src/lib.rs",
"echo x >| src/lib.rs",
"echo x >>| src/lib.rs",
"echo x >! src/lib.rs",
"echo x >>!src/lib.rs",
"echo $(echo x >| src/lib.rs)",
] {
assert_eq!(targets(command), vec!["src/lib.rs"], "{command}");
}
}
#[test]
fn write_targets_read_a_descriptor_redirect_that_names_a_file() {
for command in [
"echo x >&src/lib.rs",
"echo x >& src/lib.rs",
"echo x >&!src/lib.rs",
"echo x >&| src/lib.rs",
"echo x 2>&1 >&src/lib.rs",
"(echo x >&src/lib.rs)",
] {
assert_eq!(targets(command), vec!["src/lib.rs"], "{command}");
}
for command in [
"cargo test 2>&1",
"echo x >&2",
"echo x 2>&-",
"echo x >& 2",
"x 1>&2-",
] {
assert_eq!(targets(command), Vec::<String>::new(), "{command}");
}
}
#[test]
fn write_targets_read_a_shell_heredoc_body() {
for command in [
"bash <<'EOF'\n$(tee src/lib.rs)\nEOF",
"bash <<'EOF'\necho $(echo x | tee src/lib.rs)\nEOF",
"sh <<'EOF'\nsh -c \"$(echo x > src/lib.rs)\"\nEOF",
"bash <<'EOF'\necho x > src/lib.rs\nEOF",
"bash <<'EOF'\necho x | tee src/lib.rs\nEOF",
"zsh <<EOF\necho x >| src/lib.rs\nEOF",
] {
assert!(
targets(command).contains(&"src/lib.rs".to_string()),
"`{command}` -> {:?}",
targets(command)
);
}
let data = "cat <<'EOF' > notes.md\n$(tee src/lib.rs)\nEOF";
assert_eq!(targets(data), vec!["notes.md"]);
}
#[test]
fn write_targets_strip_a_glued_close_paren_from_a_tee_operand() {
assert_eq!(
tee_targets("tee src/lib.rs)"),
Ok(vec!["src/lib.rs".to_string()])
);
assert_eq!(
tee_targets("$(tee src/lib.rs)"),
Ok(vec!["src/lib.rs".to_string()])
);
assert_eq!(tee_targets("tee a(1).md"), Ok(vec!["a(1).md".to_string()]));
}
#[test]
fn write_targets_reads_every_redirect_of_a_segment() {
assert_eq!(
targets("echo x > notes.md > src/lib.rs"),
vec!["notes.md", "src/lib.rs"]
);
assert_eq!(
targets("echo x > crates/x/src/$(true)lib.rs"),
vec!["crates/x/src/$()lib.rs"]
);
}
}