use crate::localization::{self, keys};
use camino::Utf8PathBuf;
use shell_quote::{QuoteRefExt, Sh};
#[cfg(test)]
use std::cell::Cell;
use super::IrGenError;
use crate::recipe_shell::RecipeShell;
mod command_substitution;
mod posix_lexical;
mod script_substitution;
mod substitution;
use script_substitution::ScriptSubstitutionTraversal;
use substitution::SubstitutionTraversal;
#[derive(Debug, Clone)]
pub(crate) struct CommandBindings {
shell: RecipeShell,
ins: PathSubstitutions,
outs: PathSubstitutions,
}
#[derive(Debug, Clone)]
struct PathSubstitutions {
unquoted: String,
single_quoted: String,
double_quoted: String,
}
impl CommandBindings {
#[must_use]
pub(crate) fn new(inputs: &[Utf8PathBuf], outputs: &[Utf8PathBuf], shell: RecipeShell) -> Self {
record_binding_preparation();
Self {
shell,
ins: PathSubstitutions::new(inputs, shell),
outs: PathSubstitutions::new(outputs, shell),
}
}
fn substitution(&self, placeholder: Placeholder, context: QuoteContext) -> &str {
let paths = match placeholder {
Placeholder::Inputs => &self.ins,
Placeholder::Outputs => &self.outs,
};
match context {
QuoteContext::Unquoted => &paths.unquoted,
QuoteContext::Single => &paths.single_quoted,
QuoteContext::Double => &paths.double_quoted,
}
}
}
impl PathSubstitutions {
fn new(paths: &[Utf8PathBuf], shell: RecipeShell) -> Self {
let unquoted = quote_paths(paths, shell).join(" ");
if shell == RecipeShell::PowerShell {
return Self {
single_quoted: unquoted.clone(),
double_quoted: unquoted.clone(),
unquoted,
};
}
Self {
unquoted,
single_quoted: paths
.iter()
.map(|path| path.as_str().replace('\'', "'\"'\"'"))
.collect::<Vec<_>>()
.join("' '"),
double_quoted: paths
.iter()
.map(|path| quote_double_quoted_path(path.as_str()))
.collect::<Vec<_>>()
.join("\" \""),
}
}
}
#[cfg(test)]
thread_local! {
static BINDING_PREPARATIONS: Cell<usize> = const { Cell::new(0) };
}
#[cfg(test)]
fn record_binding_preparation() {
BINDING_PREPARATIONS.with(|count| count.set(count.get() + 1));
}
#[cfg(not(test))]
const fn record_binding_preparation() {}
#[cfg(test)]
pub(crate) fn reset_binding_preparations() {
BINDING_PREPARATIONS.with(|count| count.set(0));
}
#[cfg(test)]
pub(crate) fn binding_preparations() -> usize {
BINDING_PREPARATIONS.with(Cell::get)
}
fn quote_paths(paths: &[Utf8PathBuf], shell: RecipeShell) -> Vec<String> {
paths.iter().map(|path| quote_path(path, shell)).collect()
}
fn quote_path(path: &Utf8PathBuf, shell: RecipeShell) -> String {
if shell == RecipeShell::PowerShell {
return format!("'{}'", path.as_str().replace('\'', "''"));
}
let bytes: Vec<u8> = path.as_str().quoted(Sh);
match String::from_utf8(bytes) {
Ok(text) => text,
Err(err) => {
debug_assert!(false, "shell quoting produced non UTF-8 bytes: {err}");
String::from_utf8_lossy(err.as_bytes()).into_owned()
}
}
}
fn quote_double_quoted_path(path: &str) -> String {
path.chars()
.flat_map(|ch| {
matches!(ch, '\\' | '"' | '$' | '`')
.then_some('\\')
.into_iter()
.chain([ch])
})
.collect()
}
fn has_unmatched_backticks(s: &str) -> bool {
s.chars().filter(|&c| c == '`').count().rem_euclid(2) != 0
}
#[cfg(test)]
pub(crate) fn interpolate_command_with_shell(
template: &str,
inputs: &[Utf8PathBuf],
outputs: &[Utf8PathBuf],
shell: RecipeShell,
) -> Result<String, IrGenError> {
let bindings = CommandBindings::new(inputs, outputs, shell);
interpolate_command_with_bindings(template, &bindings)
}
pub(crate) fn interpolate_command_with_bindings(
template: &str,
bindings: &CommandBindings,
) -> Result<String, IrGenError> {
let interpolated = substitute(template, bindings)?;
if !is_valid_command_for_shell(&interpolated, bindings.shell) {
return Err(invalid_command_error(interpolated));
}
Ok(interpolated)
}
pub(crate) fn interpolate_script_with_bindings(
template: &str,
bindings: &CommandBindings,
) -> Result<String, IrGenError> {
substitute_script(template, bindings)
}
fn invalid_command_error(command: String) -> IrGenError {
let snippet = command.chars().take(160).collect();
let message = localization::message(keys::IR_INVALID_COMMAND).with_arg("snippet", &snippet);
IrGenError::InvalidCommand {
command,
snippet,
message,
}
}
fn is_valid_command_for_shell(command: &str, shell: RecipeShell) -> bool {
if shell == RecipeShell::PowerShell {
return true;
}
!has_unmatched_backticks(command) && shlex::split(command).is_some()
}
#[derive(Debug, Clone, Copy)]
pub(super) enum Placeholder {
Inputs,
Outputs,
}
#[derive(Debug, Clone, Copy)]
pub(super) enum QuoteContext {
Unquoted,
Single,
Double,
}
pub(super) fn find_substitution(chars: &[char], pos: usize) -> Option<(Placeholder, usize)> {
try_match_token(chars, pos, INS_TOKEN, Placeholder::Inputs)
.or_else(|| try_match_token(chars, pos, OUTS_TOKEN, Placeholder::Outputs))
}
pub(super) fn find_script_substitution(chars: &[char], pos: usize) -> Option<(Placeholder, usize)> {
try_match_dollar_placeholder(chars, pos, &['i', 'n'], Placeholder::Inputs)
.or_else(|| {
try_match_dollar_placeholder(chars, pos, &['o', 'u', 't'], Placeholder::Outputs)
})
.or_else(|| find_substitution(chars, pos))
}
fn try_match_dollar_placeholder(
chars: &[char],
pos: usize,
name: &[char],
placeholder: Placeholder,
) -> Option<(Placeholder, usize)> {
let name_length = name.len();
let matches_name = chars.get(pos) == Some(&'$')
&& name
.iter()
.enumerate()
.all(|(offset, character)| chars.get(pos + offset + 1) == Some(character));
let has_boundaries = chars
.get(pos.wrapping_sub(1))
.is_none_or(|character| !character.is_ascii_alphanumeric() && *character != '_')
&& chars
.get(pos + name_length + 1)
.is_none_or(|character| !character.is_ascii_alphanumeric() && *character != '_');
(matches_name && has_boundaries).then_some((placeholder, name_length + 1))
}
fn try_match_token(
chars: &[char],
pos: usize,
token: &str,
placeholder: Placeholder,
) -> Option<(Placeholder, usize)> {
let token_len = token.chars().count();
if pos + token_len > chars.len() {
return None;
}
let mut matched_len = 0;
for (i, token_ch) in token.chars().enumerate() {
if chars.get(pos + i) != Some(&token_ch) {
return None;
}
matched_len += 1;
}
Some((placeholder, matched_len))
}
fn substitute(template: &str, bindings: &CommandBindings) -> Result<String, IrGenError> {
let chars: Vec<char> = template.chars().collect();
let mut traversal = SubstitutionTraversal::new(template, &chars, bindings);
let mut pos = 0;
while pos < chars.len() {
pos = traversal.append_substitution_at_position(pos)?;
}
Ok(traversal.finish())
}
fn substitute_script(template: &str, bindings: &CommandBindings) -> Result<String, IrGenError> {
let chars: Vec<char> = template.chars().collect();
let mut traversal = ScriptSubstitutionTraversal::new(template, &chars, bindings);
let mut pos = 0;
while pos < chars.len() {
pos = traversal.append_substitution_at_position(pos)?;
}
Ok(traversal.finish())
}
pub const INS_TOKEN: &str = "__NETSUKE_INS_PLACEHOLDER__";
pub const OUTS_TOKEN: &str = "__NETSUKE_OUTS_PLACEHOLDER__";
#[cfg(test)]
#[path = "posix_lexical_tests.rs"]
mod posix_lexical_tests;
#[cfg(test)]
#[path = "../cmd_interpolate_property_tests.rs"]
mod property_tests;
#[cfg(test)]
#[path = "../cmd_interpolate_tests.rs"]
mod tests;