use super::commands::classify_into;
use super::{ClassifyError, ShellContext, Sink};
use crate::action::AtomicAction;
const RECIPE_VARS: &[&str] = &["SHELL", ".SHELLFLAGS", "MAKESHELL", "MAKEFLAGS", "MFLAGS"];
const SHELL_VARS: &[&str] = &["SHELL", "MAKESHELL"];
const NO_VALUE_SHORT: &str = "bBdeiknpqrsStvw";
const PATH_OPTIONS: &[&str] = &["--file=", "--makefile=", "--directory=", "--include-dir="];
pub(super) fn classify(
argv: &[String],
ctx: &ShellContext<'_>,
sink: &mut Sink,
depth: u8,
) -> Result<(), ClassifyError> {
let program = &argv[0];
let flag = |sink: &mut Sink, arg: &str| {
sink.push(AtomicAction::Shell {
argv: vec![program.clone(), arg.to_owned()],
})
};
let mut rest = argv[1..].iter();
while let Some(arg) = rest.next() {
let eval = if let Some(text) = arg.strip_prefix("--eval=") {
Some(text)
} else if arg == "--eval" || arg == "-E" {
rest.next().map(String::as_str)
} else {
arg.strip_prefix("-E").filter(|t| !t.is_empty())
};
if let Some(text) = eval {
flag(sink, "--eval")?;
classify_into(text, ctx, sink, depth + 1)?;
} else if arg == "--environment-overrides" || is_env_override_cluster(arg) {
flag(sink, arg)?;
} else if let Some((name, op, value)) = assignment(arg) {
if RECIPE_VARS.contains(&name) {
flag(sink, arg)?;
}
if SHELL_VARS.contains(&name) && !value.is_empty() {
classify_into(value, ctx, sink, depth + 1)?;
}
if op == "!" {
flag(sink, arg)?;
classify_into(value, ctx, sink, depth + 1)?;
}
for call in shell_calls(value) {
classify_into(call, ctx, sink, depth + 1)?;
}
} else if let Some(path) = PATH_OPTIONS.iter().find_map(|p| arg.strip_prefix(p)) {
sink.read(ctx, path)?;
}
}
Ok(())
}
fn is_env_override_cluster(arg: &str) -> bool {
arg.strip_prefix('-').is_some_and(|letters| {
!letters.starts_with('-')
&& letters.contains('e')
&& letters.chars().all(|c| NO_VALUE_SHORT.contains(c))
})
}
fn assignment(arg: &str) -> Option<(&str, &str, &str)> {
if arg.starts_with('-') {
return None;
}
let (left, value) = arg.split_once('=')?;
let (name, op) = ["::", ":", "+", "?", "!"]
.iter()
.find_map(|op| left.strip_suffix(op).map(|n| (n, *op)))
.unwrap_or((left, ""));
let valid = !name.is_empty()
&& !name
.chars()
.any(|c| c.is_whitespace() || matches!(c, ':' | '#' | '=' | '$' | '(' | ')'));
valid.then_some((name, op, value))
}
fn shell_calls(text: &str) -> Vec<&str> {
let mut calls = Vec::new();
let mut rest = text;
while let Some(pos) = rest.find('$') {
rest = &rest[pos + 1..];
let (open, close) = match rest.chars().next() {
Some('(') => ('(', ')'),
Some('{') => ('{', '}'),
_ => continue,
};
let body = &rest[1..];
let Some(after) = body.strip_prefix("shell") else {
continue;
};
if !after.starts_with(char::is_whitespace) {
continue;
}
let mut level = 1usize;
let end = after.char_indices().find_map(|(i, c)| {
if c == open {
level += 1;
} else if c == close {
level -= 1;
}
(level == 0).then_some(i)
});
let end = end.unwrap_or(after.len());
calls.push(after[..end].trim());
rest = &after[end..];
}
calls
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn assignments_and_operators() {
assert_eq!(assignment("SHELL=/tmp/x"), Some(("SHELL", "", "/tmp/x")));
assert_eq!(
assignment(".SHELLFLAGS=-c"),
Some((".SHELLFLAGS", "", "-c"))
);
assert_eq!(assignment("X!=id"), Some(("X", "!", "id")));
assert_eq!(assignment("CC::=clang"), Some(("CC", "::", "clang")));
assert_eq!(assignment("--file=x"), None);
assert_eq!(assignment("test"), None);
}
#[test]
fn shell_calls_are_extracted() {
assert_eq!(
shell_calls("x $(shell cat ~/.ssh/id_rsa) ${shell id} $(CC) $(shellx y)"),
["cat ~/.ssh/id_rsa", "id"]
);
assert_eq!(shell_calls("$(shell echo $(X))"), ["echo $(X)"]);
assert_eq!(shell_calls("$(shell curl x"), ["curl x"]);
}
#[test]
fn env_override_clusters() {
assert!(is_env_override_cluster("-e"));
assert!(is_env_override_cluster("-ke"));
assert!(!is_env_override_cluster("-j4"));
assert!(!is_env_override_cluster("--eval"));
assert!(!is_env_override_cluster("-k"));
}
}