use crate::localization::{self, keys};
use camino::Utf8PathBuf;
use shell_quote::{QuoteRefExt, Sh};
#[cfg(test)]
use std::cell::Cell;
use super::IrGenError;
#[derive(Debug, Clone)]
pub(crate) struct CommandBindings {
ins: String,
outs: String,
}
impl CommandBindings {
#[must_use]
pub(crate) fn new(inputs: &[Utf8PathBuf], outputs: &[Utf8PathBuf]) -> Self {
record_binding_preparation();
Self {
ins: quote_paths(inputs).join(" "),
outs: quote_paths(outputs).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]) -> Vec<String> {
paths
.iter()
.map(|path| {
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()
}
}
})
.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(
template: &str,
inputs: &[Utf8PathBuf],
outputs: &[Utf8PathBuf],
) -> Result<String, IrGenError> {
let bindings = CommandBindings::new(inputs, outputs);
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.ins, &bindings.outs);
if has_unmatched_backticks(&interpolated) || shlex::split(&interpolated).is_none() {
let snippet = interpolated.chars().take(160).collect();
let message = localization::message(keys::IR_INVALID_COMMAND).with_arg("snippet", &snippet);
return Err(IrGenError::InvalidCommand {
command: interpolated,
snippet,
message,
});
}
Ok(interpolated)
}
const fn is_identifier_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_'
}
fn matches_pattern_at_position(chars: &[char], pos: usize, pattern: &[char]) -> bool {
pattern
.iter()
.enumerate()
.all(|(off, ch)| matches!(chars.get(pos + off), Some(c) if c == ch))
}
fn has_valid_word_boundaries(chars: &[char], pos: usize, len: usize) -> bool {
let prev_ok = chars
.get(pos.wrapping_sub(1))
.is_none_or(|c| !is_identifier_char(*c));
let next_ok = chars
.get(pos + len + 1)
.is_none_or(|c| !is_identifier_char(*c));
prev_ok && next_ok
}
fn try_match_placeholder(chars: &[char], pos: usize, pattern: &[char]) -> Option<usize> {
if matches_pattern_at_position(chars, pos + 1, pattern)
&& has_valid_word_boundaries(chars, pos, pattern.len())
{
Some(pattern.len() + 1)
} else {
None
}
}
fn find_substitution<'a>(
chars: &[char],
pos: usize,
ins: &'a str,
outs: &'a str,
) -> Option<(&'a str, usize)> {
(chars
.get(pos)
.is_some_and(|ch| *ch == '$')
.then_some(())
.and_then(|()| {
try_match_placeholder(chars, pos, &['i', 'n'])
.map(|skip| (ins, skip))
.or_else(|| {
try_match_placeholder(chars, pos, &['o', 'u', 't']).map(|skip| (outs, skip))
})
}))
.or_else(|| {
try_match_token(chars, pos, INS_TOKEN, ins)
.or_else(|| try_match_token(chars, pos, OUTS_TOKEN, outs))
})
}
fn try_match_token<'a>(
chars: &[char],
pos: usize,
token: &str,
replacement: &'a str,
) -> Option<(&'a str, 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((replacement, matched_len))
}
fn substitute(template: &str, ins: &str, outs: &str) -> String {
let chars: Vec<char> = template.chars().collect();
let mut out = String::with_capacity(template.len());
let mut in_backticks = false;
let mut i = 0;
while let Some(&ch) = chars.get(i) {
if ch == '`' {
in_backticks ^= true;
out.push(ch);
i += 1;
continue;
}
if in_backticks {
out.push(ch);
i += 1;
continue;
}
if let Some((replacement, skip)) = find_substitution(&chars, i, ins, outs) {
out.push_str(replacement);
i += skip;
} else {
out.push(ch);
i += 1;
}
}
out
}
pub(crate) const INS_TOKEN: &str = "__NETSUKE_INS_PLACEHOLDER__";
pub(crate) const OUTS_TOKEN: &str = "__NETSUKE_OUTS_PLACEHOLDER__";
#[cfg(test)]
#[path = "cmd_interpolate_property_tests.rs"]
mod property_tests;
#[cfg(test)]
mod tests {
use super::*;
use camino::Utf8PathBuf;
#[test]
fn interpolate_command_rejects_unbalanced_backticks() {
let path = Utf8PathBuf::from("a");
let err = interpolate_command(
"echo `",
std::slice::from_ref(&path),
std::slice::from_ref(&path),
)
.expect_err("command should be rejected");
match err {
IrGenError::InvalidCommand { command, .. } => {
assert_eq!(command, "echo `");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn interpolate_command_replaces_placeholders() {
let ins = vec![Utf8PathBuf::from("in"), Utf8PathBuf::from("aux")];
let outs = vec![Utf8PathBuf::from("out")];
let command = interpolate_command("cp $in $out", &ins, &outs).expect("command");
assert_eq!(command, "cp in aux out");
}
#[test]
fn interpolate_command_preserves_backtick_tokens() {
let ins = vec![Utf8PathBuf::from("src")];
let outs = vec![Utf8PathBuf::from("out")];
let command =
interpolate_command("echo `cat $in` && echo $out", &ins, &outs).expect("command");
assert_eq!(command, "echo `cat $in` && echo out");
}
#[test]
fn interpolate_command_preserves_braced_placeholders_in_backticks() {
let command =
interpolate_command("echo `{{ ins }}` $out", &[], &[Utf8PathBuf::from("out")])
.expect("command");
assert_eq!(command, "echo `{{ ins }}` out");
}
#[test]
fn interpolate_command_replaces_template_placeholders() {
let command = interpolate_command(
&format!("{INS_TOKEN} $out {OUTS_TOKEN}"),
&[Utf8PathBuf::from("in")],
&[Utf8PathBuf::from("out")],
)
.expect("command");
assert_eq!(command, "in out out");
}
}