netsuke-build 0.1.0-beta2

A YAML-powered Ninja/Jinja hybrid build system.
//! Command interpolation utilities for IR actions.
//!
//! Provides [`interpolate_command`], which substitutes `$in`, `$out`,
//! `__NETSUKE_INS_PLACEHOLDER__`, and `__NETSUKE_OUTS_PLACEHOLDER__` tokens
//! in recipe command strings while preserving backtick-delimited regions from
//! interpolation.  Called by [`super::from_manifest`] during IR lowering.

use crate::localization::{self, keys};
use camino::Utf8PathBuf;
use shell_quote::{QuoteRefExt, Sh};

#[cfg(test)]
use std::cell::Cell;

use super::IrGenError;

/// Quoted `$in` and `$out` substitutions prepared for one recipe.
///
/// A rule command list shares its input/output bindings, so lowering creates
/// this once and reuses it for every entry rather than re-quoting paths for
/// each command.
#[derive(Debug, Clone)]
pub(crate) struct CommandBindings {
    ins: String,
    outs: String,
}

impl CommandBindings {
    /// Quote the paths once for every command in one recipe.
    #[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| {
            // Utf8PathBuf guarantees UTF-8, and shell quoting should preserve it.
            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()
}

/// Returns `true` when the command contains an odd number of backticks.
///
/// # Examples
/// ```rust,ignore
/// assert!(has_unmatched_backticks("echo`"));
/// assert!(!has_unmatched_backticks("`echo`"));
/// ```
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)
}

/// Interpolate `template` with bindings prepared for its enclosing recipe.
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)
}

/// Returns whether `ch` is a valid identifier character (ASCII letter, digit, or underscore).
///
/// # Examples
/// ```rust,ignore
/// assert!(is_identifier_char('a'));
/// assert!(!is_identifier_char('-'));
/// ```
const fn is_identifier_char(ch: char) -> bool {
    ch.is_ascii_alphanumeric() || ch == '_'
}

/// Checks if `pattern` matches `chars` starting at `pos`.
///
/// # Examples
/// ```rust,ignore
/// let chars: Vec<char> = "in-out".chars().collect();
/// assert!(matches_pattern_at_position(&chars, 0, &['i', 'n']));
/// assert!(!matches_pattern_at_position(&chars, 3, &['i', 'n']));
/// ```
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))
}

/// Ensures characters around the token are not identifier characters.
///
/// # Examples
/// ```rust,ignore
/// let chars: Vec<char> = "$in".chars().collect();
/// assert!(has_valid_word_boundaries(&chars, 0, 2));
/// let chars: Vec<char> = "$input".chars().collect();
/// assert!(!has_valid_word_boundaries(&chars, 0, 2));
/// ```
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
}

/// Returns the skip length when `pattern` matches at `pos`.
///
/// # Examples
/// ```rust,ignore
/// let chars: Vec<char> = "$in".chars().collect();
/// let res = try_match_placeholder(&chars, 0, &['i', 'n']);
/// assert_eq!(res, Some(3));
/// ```
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
    }
}

/// Finds the appropriate substitution for `$in` or `$out` at `pos`.
///
/// # Examples
/// ```rust,ignore
/// let chars: Vec<char> = "$in".chars().collect();
/// let res = find_substitution(&chars, 0, "a", "");
/// assert_eq!(res, Some(("a", 3)));
/// ```
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
}

/// Internal marker emitted for `{{ ins }}` during manifest rendering and
/// consumed during command interpolation; it is not general template syntax.
pub(crate) const INS_TOKEN: &str = "__NETSUKE_INS_PLACEHOLDER__";

/// Internal marker emitted for `{{ outs }}` during manifest rendering and
/// consumed during command interpolation; it is not general template syntax.
pub(crate) const OUTS_TOKEN: &str = "__NETSUKE_OUTS_PLACEHOLDER__";

#[cfg(test)]
#[path = "cmd_interpolate_property_tests.rs"]
mod property_tests;
#[cfg(test)]
mod tests {
    //! Unit tests for command interpolation and backtick validation.
    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");
    }
}