#[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",
];
pub(super) fn git_subcommand(segment: &str) -> Option<String> {
let argv = shlex::split(segment)?;
let mut i = 0;
while i < argv.len() {
let tok = &argv[i];
if is_env_assignment(tok) {
i += 1;
continue;
}
if tok == "sudo" || tok == "env" {
match argv.get(i + 1) {
Some(next) if !next.starts_with('-') => {
i += 1;
continue;
}
_ => return None,
}
}
break;
}
let program = argv.get(i)?;
if program.rsplit('/').next().unwrap_or(program) != "git" {
return None;
}
i += 1;
while i < argv.len() {
let tok = &argv[i];
if tok == "--" {
return None;
}
if tok.starts_with("--") {
if tok.contains('=') {
i += 1;
continue;
}
if GIT_GLOBAL_OPTS_WITH_ARG.contains(&tok.as_str()) {
i += 2;
continue;
}
i += 1;
continue;
}
if tok.starts_with('-') && tok.len() > 1 {
if GIT_GLOBAL_OPTS_WITH_ARG.contains(&tok.as_str()) {
i += 2;
continue;
}
i += 1;
continue;
}
return Some(tok.clone());
}
None
}
fn is_env_assignment(token: &str) -> bool {
let Some((key, _)) = token.split_once('=') else {
return false;
};
let mut chars = key.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[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_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")
);
}
}