use crate::commands::hook_rewrite::{COMMAND_WRAPPERS, is_env_assignment};
pub(super) const LEADING_KEYWORDS: &[&str] = &[
"!", "{", "(", "if", "then", "else", "elif", "do", "while", "until",
];
const CARGO_VALUE_FLAGS: &[&str] = &["-C", "-Z", "--config", "--color"];
fn wrapper_value_options(wrapper: &str) -> &'static [&'static str] {
match wrapper {
"sudo" => &[
"-u",
"-g",
"-C",
"-D",
"-p",
"-r",
"-t",
"-U",
"-T",
"-R",
"--user",
"--group",
"--chdir",
"--prompt",
"--role",
"--type",
"--other-user",
"--command-timeout",
"--chroot",
"--close-from",
],
"doas" => &["-u", "-C"],
"env" => &["-u", "--unset", "-C", "--chdir", "-P"],
"nice" => &["-n", "--adjustment"],
"timeout" => &["-s", "--signal", "-k", "--kill-after"],
"ionice" => &["-c", "--class", "-n", "--classdata", "-p", "--pid"],
"stdbuf" => &["-i", "-o", "-e", "--input", "--output", "--error"],
"time" => &["-f", "--format", "-o", "--output"],
"exec" => &["-a"],
"caffeinate" => &["-t", "-w"],
_ => &[],
}
}
pub(super) struct Word {
pub(super) start: usize,
pub(super) end: usize,
pub(super) text: String,
}
pub(super) fn words(seg: &str) -> Vec<Word> {
let bytes = seg.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
let start = i;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
match quote {
Some(q) if b == q => quote = None,
Some(b'"') if b == b'\\' => i += 1,
Some(_) => {}
None if b.is_ascii_whitespace() => break,
None if b == b'\'' || b == b'"' => quote = Some(b),
None if b == b'\\' => i += 1,
None => {}
}
i += 1;
}
let end = i.min(bytes.len());
let raw = &seg[start..end];
let text = shlex::split(raw)
.map(|parts| parts.concat())
.unwrap_or_else(|| raw.to_string());
out.push(Word { start, end, text });
}
out
}
pub(super) fn heavy_program_offset(seg: &str, heavy: &[(String, Option<String>)]) -> Option<usize> {
let words = words(seg);
let mut idx = 0;
let mut wrapper: Option<String> = None;
let mut outside: Option<usize> = None;
while let Some(word) = words.get(idx) {
let text = word.text.as_str();
let bare = text.trim_start_matches('(');
let lead = text.len() - bare.len();
if text == "case" {
idx = words
.iter()
.skip(idx)
.position(|w| w.text == "in")
.map_or(words.len(), |p| idx + p + 1);
continue;
}
if LEADING_KEYWORDS.contains(&text) || bare.is_empty() || is_case_label(bare) {
idx += 1;
continue;
}
let base = bare.rsplit('/').next().unwrap_or(bare);
if is_env_assignment(bare) || COMMAND_WRAPPERS.contains(&base) {
if (base == "sudo" || base == "doas") && outside.is_none() {
outside = Some(word.start + lead);
}
if COMMAND_WRAPPERS.contains(&base) {
wrapper = Some(base.to_string());
}
idx += 1;
continue;
}
if base == "rustup" && words.get(idx + 1).is_some_and(|w| w.text == "run") {
outside.get_or_insert(word.start + lead);
idx += 2;
while words.get(idx).is_some_and(|w| w.text.starts_with('-')) {
idx += 1;
}
idx += 1; wrapper = None;
continue;
}
if let Some(w) = wrapper.as_deref() {
if wrapper_value_options(w).contains(&bare) {
idx += 2;
continue;
}
if bare.starts_with('-') || bare.parse::<f64>().is_ok() || is_duration(bare) {
idx += 1;
continue;
}
}
break;
}
let word = words.get(idx)?;
let lead = word.text.len() - word.text.trim_start_matches('(').len();
let program = word.text.trim_start_matches('(');
let program = program.rsplit('/').next().unwrap_or(program);
let sub = subcommand(&words[idx + 1..]);
let is_heavy = heavy
.iter()
.any(|(p, s)| p == program && s.as_deref().is_none_or(|s| Some(s) == sub.as_deref()));
(is_heavy && !invokes_no_compiler(program, sub.as_deref(), &words[idx + 1..]))
.then_some(outside.unwrap_or(word.start + lead))
}
pub(super) fn find_exec_commands(seg: &str) -> Vec<String> {
let words = words(seg);
let is_find = words
.iter()
.find(|w| !is_env_assignment(&w.text) && !LEADING_KEYWORDS.contains(&w.text.as_str()))
.is_some_and(|w| w.text.rsplit('/').next() == Some("find"));
if !is_find {
return Vec::new();
}
let mut out = Vec::new();
let mut iter = words.iter().map(|w| w.text.as_str());
while let Some(word) = iter.next() {
if matches!(word, "-exec" | "-execdir" | "-ok" | "-okdir") {
let argv: Vec<&str> = iter
.by_ref()
.take_while(|w| !matches!(*w, ";" | "+"))
.collect();
if let Ok(joined) = shlex::try_join(argv) {
out.push(joined);
}
}
}
out
}
const CARGO_NO_COMPILE_FLAGS: &[&str] = &["--help", "-h", "--version", "-V"];
fn invokes_no_compiler(program: &str, sub: Option<&str>, rest: &[Word]) -> bool {
program == "cargo"
&& rest
.iter()
.map(|w| w.text.as_str())
.take_while(|w| *w != "--")
.any(|w| {
CARGO_NO_COMPILE_FLAGS.contains(&w) || (sub == Some("install") && w == "--list")
})
}
fn is_case_label(word: &str) -> bool {
word.ends_with(')') && !word.starts_with('$')
}
fn subcommand(rest: &[Word]) -> Option<String> {
let mut iter = rest.iter();
while let Some(word) = iter.next() {
let text = word.text.as_str();
if text.starts_with('+') {
continue;
}
if CARGO_VALUE_FLAGS.contains(&text) {
iter.next();
continue;
}
if text.starts_with('-') {
continue;
}
return Some(text.trim_end_matches([')', ';', '}']).to_string());
}
None
}
fn is_duration(word: &str) -> bool {
word.strip_suffix(['s', 'm', 'h', 'd'])
.is_some_and(|n| n.parse::<f64>().is_ok())
}