use crate::commands::hook_rewrite::{is_env_assignment, strip_wrapper_prefix};
use crate::commands::program_word::{
COMMAND_WRAPPERS, Unresolved, XARGS_OPTS_WITH_ARG, resolve_program_word,
};
pub(super) const DASH_C_SHELLS: &[&str] = &["sh", "bash", "zsh", "dash", "ksh", "ash"];
const SHELL_PRECOMMANDS: &[&str] = &["command", "builtin", "noglob", "nocorrect", "exec", "time"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Scope {
Current,
Child,
Unplaceable,
}
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 {
wrapped_command_scoped(segment).0
}
pub(super) fn wrapped_command_scoped(segment: &str) -> (WrappedCommand, Scope) {
let Some(argv) = shlex::split(segment) else {
return (WrappedCommand::None, Scope::Child);
};
let carried = |at: usize| {
carried_string(&argv[at], &argv[at + 1..]).map(|inner| {
let scope = if carrier(&argv[at]) == "eval" {
eval_scope(&argv[..at])
} else {
Scope::Child
};
(inner, scope)
})
};
let found = match resolve_program_word(&argv) {
Ok(word) if word.lookup => None,
Ok(word) => Some(word.xargs_at.unwrap_or(word.index))
.filter(|&at| at < argv.len())
.and_then(carried),
Err(Unresolved) => (0..argv.len()).find_map(carried),
};
match found {
Some((inner, scope)) => (inner_or_none(Some(inner)), scope),
None => (WrappedCommand::None, Scope::Child),
}
}
fn eval_scope(prefix: &[String]) -> Scope {
let words: Vec<&str> = prefix
.iter()
.map(String::as_str)
.skip_while(|w| is_env_assignment(w))
.collect();
match words.as_slice() {
[] | ["builtin"] => Scope::Current,
[first, ..]
if COMMAND_WRAPPERS.contains(&carrier(first))
&& !SHELL_PRECOMMANDS.contains(&carrier(first)) =>
{
Scope::Child
}
_ => Scope::Unplaceable,
}
}
fn carrier(program: &str) -> &str {
let tok = program.strip_prefix('\\').unwrap_or(program);
tok.rsplit('/').next().unwrap_or(tok)
}
fn carried_string(program: &str, rest: &[String]) -> Option<String> {
let base = carrier(program);
match base {
_ if DASH_C_SHELLS.contains(&base) => dash_c_argument(rest),
"env" | "genv" => env_split_string(rest),
"flock" => flock_command(rest),
"xargs" => xargs_argument(rest),
"eval" => {
let operands = rest
.split_first()
.filter(|(head, _)| *head == "--")
.map_or(rest, |(_, tail)| tail);
Some(operands.join(" "))
}
_ => 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,
}
}
const SHELL_LONG_OPTS_WITH_ARG: &[&str] = &["--rcfile", "--init-file"];
fn dash_c_argument(rest: &[String]) -> Option<String> {
let mut saw_c = false;
let mut i = 0;
while let Some(tok) = rest.get(i) {
if tok == "--" || tok == "-" {
i += 1;
break;
}
if tok.starts_with("--") {
i += if SHELL_LONG_OPTS_WITH_ARG.contains(&tok.as_str()) {
2
} else {
1
};
continue;
}
let Some(cluster) = tok.strip_prefix('-').or_else(|| tok.strip_prefix('+')) else {
break;
};
saw_c |= tok.starts_with('-') && cluster.contains('c');
i += if cluster.contains(['o', 'O']) { 2 } else { 1 };
}
if saw_c { rest.get(i).cloned() } else { None }
}
fn env_split_string(rest: &[String]) -> Option<String> {
option_string(
rest,
'S',
"--split-string",
&["-C", "-P", "-u", "--chdir", "--unset"],
)
}
fn flock_command(rest: &[String]) -> Option<String> {
let valued = &["-E", "-w", "--conflict-exit-code", "--timeout", "--wait"];
if let Some(string) = option_string(rest, 'c', "--command", valued) {
return Some(string);
}
let file = options_end(rest, valued);
option_string(rest.get(file + 1..)?, 'c', "--command", &[])
}
fn option_string(rest: &[String], short: char, long: &str, valued: &[&str]) -> Option<String> {
let mut i = 0;
while let Some(tok) = rest.get(i) {
if tok == "-" {
i += 1;
continue;
}
if tok == "--" || !tok.starts_with('-') {
return None;
}
if tok == long {
return rest.get(i + 1).cloned();
}
if let Some(value) = tok.strip_prefix(long).and_then(|v| v.strip_prefix('=')) {
return Some(value.to_string());
}
let mut width = 1;
if !tok.starts_with("--") {
for (at, c) in tok.char_indices().skip(1) {
let value = &tok[at + c.len_utf8()..];
if c == short {
return if value.is_empty() {
rest.get(i + 1).cloned()
} else {
Some(value.to_string())
};
}
if valued.iter().any(|v| v.len() == 2 && v.ends_with(c)) {
width = if value.is_empty() { 2 } else { 1 };
break;
}
}
} else if valued.contains(&tok.as_str()) {
width = 2;
}
i += width;
}
None
}
fn options_end(rest: &[String], valued: &[&str]) -> usize {
let mut i = 0;
while let Some(tok) = rest.get(i) {
if tok == "--" {
return i + 1;
}
if !tok.starts_with('-') || tok == "-" {
return i;
}
i += if valued.contains(&tok.as_str()) { 2 } else { 1 };
}
i
}
fn xargs_argument(rest: &[String]) -> Option<String> {
let i = xargs_utility_index(rest);
if i >= rest.len() {
return None;
}
shlex::try_join(rest[i..].iter().map(String::as_str)).ok()
}
pub(super) fn xargs_utility_index(rest: &[String]) -> usize {
let mut i = 0;
while i < rest.len() {
let tok = rest[i].as_str();
if !tok.starts_with('-') || tok == "-" {
break;
}
i += if XARGS_OPTS_WITH_ARG.contains(&tok) {
2
} else {
1
};
}
i.min(rest.len())
}
#[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
);
}
}