use crate::localization::{self, keys};
use camino::Utf8PathBuf;
use shell_quote::{QuoteRefExt, Sh};
use super::IrGenError;
fn has_unmatched_backticks(s: &str) -> bool {
s.chars().filter(|&c| c == '`').count().rem_euclid(2) != 0
}
pub(crate) fn interpolate_command(
template: &str,
inputs: &[Utf8PathBuf],
outputs: &[Utf8PathBuf],
) -> Result<String, IrGenError> {
fn quote_paths(paths: &[Utf8PathBuf]) -> Vec<String> {
paths
.iter()
.map(|p| {
let bytes: Vec<u8> = p.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()
}
let ins = quote_paths(inputs);
let outs = quote_paths(outputs);
let interpolated = substitute(template, &ins, &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: &[String], outs: &[String]) -> String {
let ins_joined = ins.join(" ");
let outs_joined = outs.join(" ");
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_joined, &outs_joined) {
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");
}
}