use crate::commands::hook_rewrite::{COMMAND_WRAPPERS, is_env_assignment, strip_wrapper_prefix};
pub(super) const DASH_C_SHELLS: &[&str] = &["sh", "bash", "zsh", "dash", "ksh", "ash"];
const XARGS_OPTS_WITH_ARG: &[&str] = &[
"-a",
"-d",
"-E",
"-e",
"-I",
"-i",
"-L",
"-l",
"-n",
"-P",
"-s",
"--arg-file",
"--delimiter",
"--eof",
"--replace",
"--max-lines",
"--max-args",
"--max-procs",
"--max-chars",
"--process-slot-var",
];
pub(super) fn has_live_ansi_c_quoting(segment: &str) -> bool {
let scan = QuoteScan::new(segment);
let bytes = segment.as_bytes();
bytes.iter().enumerate().any(|(i, b)| {
*b == b'$'
&& matches!(bytes.get(i + 1), Some(b'\'') | Some(b'"'))
&& (!scan.balanced || scan.is_unquoted(i))
})
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum WrappedCommand {
None,
Inner(String),
Unlexable,
}
pub(super) fn wrapped_command(segment: &str) -> WrappedCommand {
let Some(argv) = shlex::split(segment) else {
return WrappedCommand::None;
};
let mut i = 0;
while i < argv.len() {
let raw = argv[i].as_str();
let tok = raw.strip_prefix('\\').unwrap_or(raw);
if is_env_assignment(tok) {
i += 1;
continue;
}
let base = tok.rsplit('/').next().unwrap_or(tok);
let rest = &argv[i + 1..];
if DASH_C_SHELLS.contains(&base) {
return inner_or_none(dash_c_argument(rest));
}
if base == "env" {
match env_split_string(rest) {
Some(inner) => return inner_or_none(Some(inner)),
None => {
i += 1;
continue;
}
}
}
if base == "xargs" {
return inner_or_none(xargs_argument(rest));
}
if COMMAND_WRAPPERS.contains(&tok) {
i += 1;
continue;
}
return WrappedCommand::None;
}
WrappedCommand::None
}
fn inner_or_none(inner: Option<String>) -> WrappedCommand {
let Some(inner) = inner else {
return WrappedCommand::None;
};
if inner.trim().is_empty() {
return WrappedCommand::None;
}
match shlex::split(&inner) {
Some(_) => WrappedCommand::Inner(inner),
None => WrappedCommand::Unlexable,
}
}
fn dash_c_argument(rest: &[String]) -> Option<String> {
for (n, tok) in rest.iter().enumerate() {
let cluster =
tok.starts_with('-') && !tok.starts_with("--") && tok.len() > 1 && tok.ends_with('c');
if tok == "-c" || cluster {
return rest.get(n + 1).cloned();
}
}
None
}
fn env_split_string(rest: &[String]) -> Option<String> {
for (n, tok) in rest.iter().enumerate() {
if tok == "-S" || tok == "--split-string" {
return rest.get(n + 1).cloned();
}
if let Some(value) = tok.strip_prefix("--split-string=") {
return Some(value.to_string());
}
if let Some(value) = tok.strip_prefix("-S")
&& !value.is_empty()
{
return Some(value.to_string());
}
}
None
}
fn xargs_argument(rest: &[String]) -> Option<String> {
let mut i = 0;
while i < rest.len() {
let tok = rest[i].as_str();
if !tok.starts_with('-') || tok == "-" {
break;
}
if XARGS_OPTS_WITH_ARG.contains(&tok) {
i += 2;
continue;
}
i += 1;
}
if i >= rest.len() {
return None;
}
shlex::try_join(rest[i..].iter().map(String::as_str)).ok()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Quote {
None,
Single,
Double,
}
pub(super) struct QuoteScan {
states: Vec<Quote>,
pub(super) balanced: bool,
}
impl QuoteScan {
pub(super) fn new(command: &str) -> Self {
let bytes = command.as_bytes();
let mut states = Vec::with_capacity(bytes.len());
let mut cur = Quote::None;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
match cur {
Quote::Single => {
states.push(Quote::Single);
if c == b'\'' {
cur = Quote::None;
}
i += 1;
}
Quote::Double => {
states.push(Quote::Double);
if c == b'\\' && i + 1 < bytes.len() {
states.push(Quote::Double);
i += 2;
} else {
if c == b'"' {
cur = Quote::None;
}
i += 1;
}
}
Quote::None => {
if c == b'\\' && i + 1 < bytes.len() {
states.push(Quote::None);
states.push(Quote::None);
i += 2;
} else if c == b'\'' {
states.push(Quote::Single);
cur = Quote::Single;
i += 1;
} else if c == b'"' {
states.push(Quote::Double);
cur = Quote::Double;
i += 1;
} else {
states.push(Quote::None);
i += 1;
}
}
}
}
QuoteScan {
states,
balanced: cur == Quote::None,
}
}
fn at(&self, idx: usize) -> Quote {
self.states.get(idx).copied().unwrap_or(Quote::None)
}
pub(super) fn is_unquoted(&self, idx: usize) -> bool {
self.at(idx) == Quote::None
}
pub(super) fn allows_substitution(&self, idx: usize) -> bool {
self.at(idx) != Quote::Single
}
}
const GIT_GLOBAL_OPTS_WITH_ARG: &[&str] = &[
"-C",
"-c",
"--git-dir",
"--work-tree",
"--namespace",
"--super-prefix",
"--config-env",
"--attr-source",
];
const GIT_GLOBAL_FLAGS: &[&str] = &[
"-p",
"--paginate",
"-P",
"--no-pager",
"--bare",
"--no-replace-objects",
"--no-lazy-fetch",
"--no-optional-locks",
"--no-advice",
"--literal-pathspecs",
"--glob-pathspecs",
"--noglob-pathspecs",
"--icase-pathspecs",
"--exec-path",
"--html-path",
"--man-path",
"--info-path",
"--version",
"--help",
"-v",
"-h",
];
const GIT_GLOBAL_OPTS_JOINED: &[&str] = &["--exec-path", "--list-cmds"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GitWalk {
At(usize),
End,
Unknown,
}
fn walk_git_globals(argv: &[String], start: usize) -> GitWalk {
let mut i = start;
while let Some(tok) = argv.get(i) {
if !tok.starts_with('-') || tok == "-" {
return GitWalk::At(i);
}
if GIT_GLOBAL_FLAGS.contains(&tok.as_str()) {
i += 1;
continue;
}
if GIT_GLOBAL_OPTS_WITH_ARG.contains(&tok.as_str()) {
if argv.get(i + 1).is_none() {
return GitWalk::Unknown;
}
i += 2;
continue;
}
let joined = tok.split_once('=').is_some_and(|(name, _)| {
name.starts_with("--")
&& (GIT_GLOBAL_OPTS_WITH_ARG.contains(&name)
|| GIT_GLOBAL_OPTS_JOINED.contains(&name))
});
if joined {
i += 1;
continue;
}
return GitWalk::Unknown;
}
GitWalk::End
}
fn git_program_argv(segment: &str) -> Option<(Vec<String>, usize)> {
let argv = shlex::split(segment)?;
let i = strip_wrapper_prefix(&argv)?;
let program = argv.get(i)?;
let program = program.strip_prefix('\\').unwrap_or(program);
if program.rsplit('/').next().unwrap_or(program) != "git" {
return None;
}
Some((argv, i))
}
pub(crate) fn git_subcommand_candidates(segment: &str) -> Option<(Vec<String>, Vec<usize>)> {
let (argv, program) = git_program_argv(segment)?;
let candidates = match walk_git_globals(&argv, program + 1) {
GitWalk::At(i) => vec![i],
GitWalk::End => Vec::new(),
GitWalk::Unknown => (program + 1..argv.len())
.filter(|&i| !argv[i].starts_with('-'))
.collect(),
};
Some((argv, candidates))
}
pub(crate) fn git_may_run(segment: &str, verb: &str) -> bool {
git_subcommand_candidates(segment).is_some_and(|(argv, at)| at.iter().any(|&i| argv[i] == verb))
}
pub(crate) fn git_subcommand(segment: &str) -> Option<String> {
let (argv, i) = git_argv_at_subcommand(segment)?;
Some(argv[i].clone())
}
pub(crate) fn git_argv_at_subcommand(segment: &str) -> Option<(Vec<String>, usize)> {
let (argv, program) = git_program_argv(segment)?;
match walk_git_globals(&argv, program + 1) {
GitWalk::At(i) => Some((argv, i)),
GitWalk::End | GitWalk::Unknown => None,
}
}
const GIT_SUBCOMMAND_WRITE_OPTS: &[(&str, &[&str])] = &[
("format-patch", &["-o", "--output-directory"]),
("archive", &["-o"]),
];
const GIT_OUTPUT_OPT: &[&str] = &["--output"];
pub(crate) fn git_file_write_target(segment: &str) -> Option<String> {
let (argv, candidates) = git_subcommand_candidates(segment)?;
candidates
.into_iter()
.find_map(|at| write_target_at(&argv, at))
}
fn write_target_at(argv: &[String], subcommand: usize) -> Option<String> {
let sub = argv[subcommand].as_str();
let args = &argv[subcommand + 1..];
if sub == "bundle" {
return bundle_create_target(args);
}
let extra = GIT_SUBCOMMAND_WRITE_OPTS
.iter()
.find(|(name, _)| *name == sub)
.map_or(&[] as &[&str], |(_, opts)| *opts);
let mut stdout_requested = false;
for (i, tok) in args.iter().enumerate() {
if tok == "--" {
break;
}
if tok == "--stdout" {
stdout_requested = true;
continue;
}
let next = args.get(i + 1);
if let Some(target) =
option_value(tok, next, GIT_OUTPUT_OPT).or_else(|| option_value(tok, next, extra))
{
return Some(target);
}
}
if sub == "format-patch" && !stdout_requested {
return Some(String::new());
}
None
}
fn option_value(tok: &str, next: Option<&String>, opts: &[&str]) -> Option<String> {
for opt in opts {
if tok == *opt {
return Some(next.cloned().unwrap_or_default());
}
if let Some(rest) = tok.strip_prefix(opt)
&& let Some(value) = rest.strip_prefix('=')
{
return Some(value.to_string());
}
if !opt.starts_with("--")
&& let Some(value) = tok.strip_prefix(opt)
&& !value.is_empty()
{
return Some(value.to_string());
}
}
None
}
fn bundle_create_target(args: &[String]) -> Option<String> {
let mut rest = args.iter();
if rest.next().map(String::as_str) != Some("create") {
return None;
}
for tok in rest {
if tok == "-" {
return None;
}
if tok.starts_with('-') {
continue;
}
return Some(tok.clone());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_scan_marks_single_and_double() {
let scan = QuoteScan::new("a '>' b \">\" c");
assert!(scan.balanced);
assert!(scan.is_unquoted(0));
let s: String = "a '>' b \">\" c".to_string();
let first_gt = s.find('>').unwrap();
let last_gt = s.rfind('>').unwrap();
assert!(!scan.is_unquoted(first_gt), "first > is single-quoted");
assert!(!scan.is_unquoted(last_gt), "second > is double-quoted");
}
#[test]
fn quote_scan_backslash_escape() {
let scan = QuoteScan::new(r#"echo \" ok"#);
assert!(scan.balanced, "escaped quote must not open a span");
}
#[test]
fn quote_scan_reports_unbalanced() {
assert!(!QuoteScan::new("echo 'unterminated").balanced);
assert!(!QuoteScan::new("echo \"unterminated").balanced);
assert!(QuoteScan::new("echo 'closed'").balanced);
}
#[test]
fn quote_scan_substitution_live_in_double_quotes() {
let dq = "echo \"$(x)\"";
let scan = QuoteScan::new(dq);
let dollar = dq.find('$').unwrap();
assert!(scan.allows_substitution(dollar), "double-quoted $( is live");
let sq = "echo '$(x)'";
let scan = QuoteScan::new(sq);
let dollar = sq.find('$').unwrap();
assert!(
!scan.allows_substitution(dollar),
"single-quoted $( is literal"
);
}
#[test]
fn git_subcommand_plain() {
assert_eq!(git_subcommand("git commit -m x").as_deref(), Some("commit"));
assert_eq!(git_subcommand("git status").as_deref(), Some("status"));
assert_eq!(git_subcommand("git log --oneline").as_deref(), Some("log"));
assert_eq!(git_subcommand("git apply p.diff").as_deref(), Some("apply"));
}
#[test]
fn git_subcommand_skips_global_flags() {
assert_eq!(
git_subcommand("git -C /some/path commit -m x").as_deref(),
Some("commit")
);
assert_eq!(
git_subcommand("git -C /some/path apply p.diff").as_deref(),
Some("apply")
);
assert_eq!(
git_subcommand("git -c user.name=x commit").as_deref(),
Some("commit")
);
assert_eq!(
git_subcommand("git --git-dir=/p/.git status").as_deref(),
Some("status")
);
assert_eq!(
git_subcommand("git --work-tree /p -C /p diff").as_deref(),
Some("diff")
);
assert_eq!(
git_subcommand("FOO=bar git -C /p push").as_deref(),
Some("push")
);
}
#[test]
fn git_subcommand_none_for_non_git() {
assert_eq!(git_subcommand("ls -la"), None);
assert_eq!(git_subcommand("cargo test"), None);
assert_eq!(git_subcommand("gitfoo status"), None);
}
#[test]
fn git_subcommand_none_for_an_unknown_global_flag() {
for command in [
"git --shallow-file status checkout -- Cargo.toml",
"git --shallow-file log reset --hard",
"git --bogus=x status",
"git -C",
"git -- status",
] {
assert_eq!(git_subcommand(command), None, "{command}");
assert_eq!(git_argv_at_subcommand(command), None, "{command}");
}
}
#[test]
fn git_subcommand_resolves_every_known_global_flag() {
for (command, sub) in [
(
"git --attr-source status checkout -- Cargo.toml",
"checkout",
),
("git --attr-source=HEAD log", "log"),
("git --no-pager -P --no-optional-locks diff", "diff"),
("git --exec-path=/x --literal-pathspecs status", "status"),
("git --config-env=a=B commit", "commit"),
] {
assert_eq!(git_subcommand(command).as_deref(), Some(sub), "{command}");
}
assert_eq!(git_subcommand("git --version"), None);
}
#[test]
fn git_subcommand_candidates_cover_every_position_when_unresolved() {
let (argv, at) = git_subcommand_candidates("git --shallow-file status checkout -- f")
.expect("a git segment");
let names: Vec<&str> = at.iter().map(|&i| argv[i].as_str()).collect();
assert_eq!(names, ["status", "checkout", "f"]);
assert!(git_may_run("git --shallow-file x apply p.diff", "apply"));
assert!(!git_may_run("git status", "apply"));
assert!(!git_may_run("ls apply", "apply"));
assert_eq!(
git_file_write_target("git --shallow-file x diff --output=/tmp/o.diff").as_deref(),
Some("/tmp/o.diff")
);
}
#[test]
fn git_subcommand_none_when_unbalanced() {
assert_eq!(git_subcommand("git commit -m 'unterminated"), None);
}
#[test]
fn git_subcommand_resolves_path_program() {
assert_eq!(
git_subcommand("/usr/bin/git -C /p commit").as_deref(),
Some("commit")
);
}
#[test]
fn git_subcommand_resolves_through_command_and_nice_wrappers() {
for command in ["command git apply -", "nice git reset --hard"] {
assert!(
git_subcommand(command).is_some(),
"expected a resolved subcommand for: {command}"
);
}
assert_eq!(
git_subcommand("command git apply -").as_deref(),
Some("apply")
);
assert_eq!(
git_subcommand("nice git reset --hard").as_deref(),
Some("reset")
);
}
#[test]
fn git_file_write_target_finds_both_output_spellings() {
assert_eq!(
git_file_write_target("git diff --output=/tmp/o.diff").as_deref(),
Some("/tmp/o.diff")
);
assert_eq!(
git_file_write_target("git diff --output /tmp/o.diff HEAD").as_deref(),
Some("/tmp/o.diff")
);
assert_eq!(
git_file_write_target("git -C /repo --no-pager log -1 --output=/tmp/l.txt").as_deref(),
Some("/tmp/l.txt")
);
assert_eq!(
git_file_write_target(r#"git diff --output="/tmp/my out.diff""#).as_deref(),
Some("/tmp/my out.diff")
);
assert_eq!(
git_file_write_target("git diff --output").as_deref(),
Some("")
);
}
#[test]
fn git_file_write_target_finds_the_short_output_options() {
for (command, target) in [
("git format-patch -o /tmp/d -1 HEAD", "/tmp/d"),
("git format-patch -1 -o/tmp/d HEAD", "/tmp/d"),
(
"git format-patch --output-directory=/tmp/d -1 HEAD",
"/tmp/d",
),
(
"git format-patch --output-directory /tmp/d -1 HEAD",
"/tmp/d",
),
("git archive -o /tmp/a.tar HEAD", "/tmp/a.tar"),
("git archive -o/tmp/a.tar HEAD", "/tmp/a.tar"),
("git archive --output=/tmp/a.tar HEAD", "/tmp/a.tar"),
] {
assert_eq!(
git_file_write_target(command).as_deref(),
Some(target),
"{command}"
);
}
assert_eq!(git_file_write_target("git diff -o /tmp/o.diff HEAD"), None);
assert_eq!(
git_file_write_target("git clone -o upstream https://x/y.git"),
None
);
}
#[test]
fn git_file_write_target_reads_format_patch_and_bundle_by_shape() {
assert_eq!(
git_file_write_target("git format-patch -1 HEAD").as_deref(),
Some("")
);
assert_eq!(
git_file_write_target("git format-patch --stdout -1 HEAD"),
None
);
assert_eq!(
git_file_write_target("git format-patch -1 HEAD --stdout"),
None
);
assert_eq!(
git_file_write_target("git bundle create /tmp/x.bundle HEAD").as_deref(),
Some("/tmp/x.bundle")
);
assert_eq!(
git_file_write_target("git bundle create -q /tmp/x.bundle --all").as_deref(),
Some("/tmp/x.bundle")
);
assert_eq!(git_file_write_target("git bundle create - HEAD"), None);
assert_eq!(
git_file_write_target("git bundle verify /tmp/x.bundle"),
None
);
assert_eq!(
git_file_write_target("git bundle list-heads /tmp/x.bundle"),
None
);
}
#[test]
fn git_file_write_target_ignores_the_indicator_options() {
for command in [
"git diff --output-indicator-new=+",
"git diff --output-indicator-old=- --stat",
"git diff --output-indicator-context= ",
"git diff --stat -- docs/",
"git diff --no-index /tmp/a.txt /tmp/b.txt",
"git archive --format=tar HEAD",
] {
assert_eq!(git_file_write_target(command), None, "{command}");
}
}
#[test]
fn git_file_write_target_stops_at_the_pathspec_separator() {
assert_eq!(
git_file_write_target("git diff -- --output=/tmp/o.diff"),
None
);
assert_eq!(
git_file_write_target("git diff --output=/tmp/o.diff -- docs/").as_deref(),
Some("/tmp/o.diff")
);
}
#[test]
fn git_file_write_target_none_when_unbalanced() {
assert_eq!(
git_file_write_target("git diff --output='/tmp/o.diff"),
None
);
assert_eq!(
git_file_write_target("cargo run -- --output=/tmp/o.diff"),
None
);
}
}