use sha2::{Digest, Sha256};
use crate::hex::to_lower_hex;
#[path = "ninja_gen_command_list_scanner.rs"]
mod scanner;
use scanner::background_operator_count;
pub(crate) const COMMAND_LIST_FAILURE_PREFIX: &str = "netsuke command-list failure: action ";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum CommandListEntryError {
MultipleBackgroundJobs,
UnsupportedExec,
UnanalyzableEval,
NinjaControlCharacter,
}
#[derive(Clone, Copy)]
pub(super) struct CommandListEntry<'a>(pub(super) &'a str);
#[derive(Clone, Copy)]
pub(super) struct ActionId<'a>(pub(super) &'a str);
#[derive(Clone, Copy)]
struct ShellWord<'a>(&'a str);
struct ShellWords(Vec<String>);
#[derive(Clone, Copy)]
struct UnanalyzableEval;
pub(super) fn command_list_entry_error(
command: CommandListEntry<'_>,
) -> Option<CommandListEntryError> {
let direct_background_jobs = background_operator_count(command);
if command.has_ninja_control_character() {
Some(CommandListEntryError::NinjaControlCharacter)
} else if let Some(words) = ShellWords::parse(command) {
let Ok(nested_jobs) = words.background_job_count() else {
return Some(CommandListEntryError::UnanalyzableEval);
};
if direct_background_jobs
.checked_add(nested_jobs)
.is_none_or(|background_jobs| background_jobs > 1)
{
Some(CommandListEntryError::MultipleBackgroundJobs)
} else if exec_boundary(command) == ExecBoundary::Unsupported {
Some(CommandListEntryError::UnsupportedExec)
} else {
None
}
} else if direct_background_jobs > 1 {
Some(CommandListEntryError::MultipleBackgroundJobs)
} else if exec_boundary(command) == ExecBoundary::Unsupported {
Some(CommandListEntryError::UnsupportedExec)
} else {
None
}
}
pub(super) fn command_list_entry(
command: CommandListEntry<'_>,
action_id: ActionId<'_>,
entry_index: usize,
) -> String {
let identity = action_identity(action_id);
let context = format!("{COMMAND_LIST_FAILURE_PREFIX}{identity}, entry {entry_index}");
let evaluator = command_evaluator(command);
format!(
concat!(
"{{ _netsuke_background_before=$${{!:-}}; _netsuke_exec_succeeded=0; ",
"trap '_netsuke_command_status=$$?; printf \"%s\\n\" \"{}\" >&2; ",
"trap - EXIT; exit \"$$_netsuke_command_status\"' EXIT; ",
"if {}; then _netsuke_command_status=0;{} else _netsuke_command_status=$$?; fi; ",
"_netsuke_background_after=$${{!:-}}; ",
"if [ -n \"$$_netsuke_background_after\" ] && ",
"[ \"$$_netsuke_background_after\" != \"$$_netsuke_background_before\" ]; then ",
"if wait \"$$_netsuke_background_after\"; then :; ",
"else _netsuke_background_status=$$?; ",
"if [ \"$$_netsuke_command_status\" -eq 0 ]; then ",
"_netsuke_command_status=$$_netsuke_background_status; fi; fi; fi; ",
"if [ \"$$_netsuke_command_status\" -eq 0 ]; then trap - EXIT; ",
"if [ \"$$_netsuke_exec_succeeded\" -eq 1 ]; then exit 0; else :; fi; ",
"else trap - EXIT; printf '%s\\n' '{}' >&2; ",
"exit \"$$_netsuke_command_status\"; fi; }}"
),
context, evaluator.shell_expression, evaluator.exec_success_fragment, context,
)
}
struct CommandEvaluator {
shell_expression: String,
exec_success_fragment: &'static str,
}
fn command_evaluator(command: CommandListEntry<'_>) -> CommandEvaluator {
let quoted = shell_single_quote(command);
if exec_boundary(command) == ExecBoundary::Direct {
CommandEvaluator {
shell_expression: format!("(eval {quoted})"),
exec_success_fragment: " _netsuke_exec_succeeded=1;",
}
} else {
CommandEvaluator {
shell_expression: format!("eval {quoted}"),
exec_success_fragment: "",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ExecBoundary {
None,
Direct,
Unsupported,
}
fn exec_boundary(command: CommandListEntry<'_>) -> ExecBoundary {
ShellWords::parse(command).map_or(ExecBoundary::None, |words| words.exec_boundary())
}
impl ShellWords {
fn parse(command: CommandListEntry<'_>) -> Option<Self> {
shlex::split(command.0).map(Self)
}
fn exec_boundary(&self) -> ExecBoundary {
let direct_index = self.first_non_assignment_index();
self.0
.iter()
.map(|word| ShellWord(word))
.enumerate()
.find_map(|(index, word)| self.exec_boundary_at(index, word, direct_index))
.unwrap_or(ExecBoundary::None)
}
fn exec_boundary_at(
&self,
index: usize,
word: ShellWord<'_>,
direct_index: Option<usize>,
) -> Option<ExecBoundary> {
if !word.is_exec() {
return None;
}
if Some(index) == direct_index {
return Some(ExecBoundary::Direct);
}
(self.is_command_word(index) || self.is_exec_wrapper(index))
.then_some(ExecBoundary::Unsupported)
}
fn first_non_assignment_index(&self) -> Option<usize> {
self.0
.iter()
.position(|word| !ShellWord(word).is_assignment())
}
fn is_command_word(&self, index: usize) -> bool {
let Some(words_before) = self.0.get(..index) else {
return false;
};
let preceding_word = words_before
.iter()
.rev()
.find(|word| !ShellWord(word).is_assignment());
preceding_word.is_none_or(|word| ShellWord(word).ends_command())
}
fn is_exec_wrapper(&self, index: usize) -> bool {
let is_wrapper = index
.checked_sub(1)
.and_then(|previous_index| self.0.get(previous_index))
.is_some_and(|word| ShellWord(word).is_exec_wrapper());
is_wrapper
&& index
.checked_sub(1)
.is_some_and(|previous| self.is_command_word(previous))
}
fn background_job_count(&self) -> Result<usize, UnanalyzableEval> {
self.background_job_count_at_depth(0)
}
fn background_job_count_at_depth(&self, depth: usize) -> Result<usize, UnanalyzableEval> {
self.0
.iter()
.map(|word| ShellWord(word))
.enumerate()
.filter(|(index, word)| word.is_eval() && self.is_command_word(*index))
.try_fold(0_usize, |count, (index, _)| {
count
.checked_add(self.background_jobs_from_eval(index, depth)?)
.ok_or(UnanalyzableEval)
})
}
fn background_jobs_from_eval(
&self,
index: usize,
depth: usize,
) -> Result<usize, UnanalyzableEval> {
const MAX_EVAL_NESTING: usize = 16;
if depth == MAX_EVAL_NESTING {
return Err(UnanalyzableEval);
}
let source = self.eval_source(index);
if source.is_empty() {
return Ok(0);
}
if ShellWord(&source).has_dynamic_expansion() {
return Err(UnanalyzableEval);
}
let nested = CommandListEntry(&source);
background_operator_count(nested)
.checked_add(
Self::parse(nested)
.ok_or(UnanalyzableEval)?
.background_job_count_at_depth(depth + 1)?,
)
.ok_or(UnanalyzableEval)
}
fn eval_source(&self, index: usize) -> String {
index
.checked_add(1)
.and_then(|first_argument| self.0.get(first_argument..))
.unwrap_or_default()
.iter()
.take_while(|word| !ShellWord(word).is_list_operator())
.cloned()
.collect::<Vec<_>>()
.join(" ")
}
}
impl ShellWord<'_> {
fn is_exec(self) -> bool {
self.0 == "exec"
}
fn is_eval(self) -> bool {
self.0 == "eval"
}
fn is_exec_wrapper(self) -> bool {
matches!(self.0, "if" | "command")
}
fn ends_command(self) -> bool {
matches!(
self.0,
"&&" | "||"
| "|"
| "&"
| "("
| "{"
| "if"
| "then"
| "do"
| "else"
| "elif"
| "while"
| "until"
) || self.0.ends_with(';')
|| self.0.ends_with(')')
}
fn is_list_operator(self) -> bool {
matches!(self.0, "&&" | "||" | "|" | "&" | ";") || self.0.ends_with(';')
}
fn has_dynamic_expansion(self) -> bool {
self.0
.chars()
.any(|character| matches!(character, '$' | '`' | '*' | '?' | '['))
}
fn is_assignment(self) -> bool {
let Some((name, _)) = self.0.split_once('=') else {
return false;
};
let mut chars = name.chars();
chars
.next()
.is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
&& chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
}
}
impl CommandListEntry<'_> {
fn has_ninja_control_character(self) -> bool {
self.0.chars().any(char::is_control)
}
}
fn action_identity(action_id: ActionId<'_>) -> String {
to_lower_hex(&Sha256::digest(action_id.0.as_bytes()))
}
fn shell_single_quote(command: CommandListEntry<'_>) -> String {
let escaped = command.0.replace('\'', r"'\''");
format!("'{escaped}'")
}
#[cfg(test)]
#[path = "ninja_gen_command_list_tests.rs"]
mod tests;