use crate::utils::shell_scan;
const KNOWN_LONG_MARKERS: &[&str] = &[
"cargo test",
"cargo build",
"cargo run",
"cargo clippy",
"npm test",
"npm run build",
"pnpm test",
"pnpm build",
"yarn test",
"yarn build",
"npx remotion render",
"remotion render",
"gh run watch",
"gh pr checks --watch",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Detach {
Yes { marker: &'static str },
Mentioned { marker: &'static str },
No,
}
pub fn classify(command: &str) -> Detach {
let shell = shell_scan::blank_quoted(&strip_heredoc_bodies(command)).to_lowercase();
let lower = command.to_lowercase();
let mut mentioned = None;
for marker in KNOWN_LONG_MARKERS {
if marker_starts_a_command(&shell, marker) {
return Detach::Yes { marker };
}
if mentioned.is_none() && lower.contains(marker) {
mentioned = Some(*marker);
}
}
match mentioned {
Some(marker) => Detach::Mentioned { marker },
None => Detach::No,
}
}
pub fn is_known_long(command: &str) -> bool {
matches!(classify(command), Detach::Yes { .. })
}
fn strip_heredoc_bodies(command: &str) -> String {
let mut out: Vec<&str> = Vec::new();
let mut lines = command.lines();
while let Some(line) = lines.next() {
out.push(line);
let Some(delim) = heredoc_delimiter(line) else {
continue;
};
for body in lines.by_ref() {
if body.trim() == delim {
break;
}
}
}
out.join("\n")
}
fn heredoc_delimiter(line: &str) -> Option<String> {
let bytes = line.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] != b'<' || bytes[i + 1] != b'<' {
i += 1;
continue;
}
if bytes.get(i + 2) == Some(&b'<') {
i += 3;
continue;
}
let mut j = i + 2;
if bytes.get(j) == Some(&b'-') {
j += 1;
}
while matches!(bytes.get(j), Some(&b' ') | Some(&b'\t')) {
j += 1;
}
let quote = match bytes.get(j) {
Some(&b'\'') => Some(b'\''),
Some(&b'"') => Some(b'"'),
_ => None,
};
if quote.is_some() {
j += 1;
}
let start = j;
while let Some(&c) = bytes.get(j) {
match quote {
Some(q) if c == q => break,
Some(_) => j += 1,
None if c.is_ascii_alphanumeric() || c == b'_' => j += 1,
None => break,
}
}
if j > start {
return Some(line[start..j].to_string());
}
i = j.max(i + 2);
}
None
}
fn marker_starts_a_command(shell: &str, marker: &str) -> bool {
shell
.match_indices(marker)
.any(|(at, _)| is_command_position(&shell[..at]))
}
fn is_command_position(prefix: &str) -> bool {
let trimmed = prefix.trim_end_matches([' ', '\t']);
let Some(last) = trimmed.chars().last() else {
return true;
};
if matches!(last, ';' | '&' | '|' | '(' | '{' | '\n' | '`') {
return true;
}
let last_word = trimmed.rsplit([' ', '\t', '\n']).next().unwrap_or_default();
matches!(
last_word,
"do" | "then" | "else" | "time" | "exec" | "nohup"
)
}