use std::path::Path;
use crate::error::{Error, Result};
use crate::manifest::{Command, Manifest, MatchMode};
use crate::resolve;
pub struct MacroRef {
pub scope: String,
token_index: usize,
prefix: String,
suffix: String,
}
pub struct Expansion {
pub identity: String,
pub file: Option<String>,
}
pub struct Match<'a> {
pub rule: &'a Command,
pub exp: Expansion,
}
pub fn parse(name: &str) -> Result<Option<MacroRef>> {
let mut found: Option<MacroRef> = None;
for (index, token) in name.split_whitespace().enumerate() {
let Some(open) = token.find('{') else {
if token.contains('}') {
return Err(syntax(name, "unmatched `}`"));
}
continue;
};
if found.is_some() {
return Err(syntax(name, "more than one macro"));
}
let rest = &token[open + 1..];
let close = rest
.find('}')
.ok_or_else(|| syntax(name, "unmatched `{`"))?;
let scope = &rest[..close];
let suffix = &rest[close + 1..];
if scope.is_empty() {
return Err(syntax(name, "empty `{}` macro"));
}
if scope.contains('{') || suffix.contains('{') || suffix.contains('}') {
return Err(syntax(name, "more than one macro"));
}
found = Some(MacroRef {
scope: scope.to_owned(),
token_index: index,
prefix: token[..open].to_owned(),
suffix: suffix.to_owned(),
});
}
Ok(found)
}
fn syntax(name: &str, reason: &str) -> Error {
Error::MacroSyntax {
name: name.to_owned(),
reason: reason.to_owned(),
}
}
#[must_use]
pub fn expand_name(name: &str, scope: &str, file: &str) -> String {
name.replacen(&format!("{{{scope}}}"), file, 1)
}
#[must_use]
pub fn bind(mac: &MacroRef, rule: &Command, argv: &[String]) -> Option<String> {
let tokens: Vec<&str> = rule.name.split_whitespace().collect();
let fits = match rule.match_mode {
MatchMode::Prefix => argv.len() >= tokens.len(),
MatchMode::Exact => argv.len() == tokens.len(),
};
if !fits {
return None;
}
let mut file = None;
for (index, token) in tokens.iter().enumerate() {
let arg = argv.get(index)?;
if index == mac.token_index {
let bound = arg.strip_prefix(&mac.prefix)?.strip_suffix(&mac.suffix)?;
if bound.is_empty() {
return None;
}
file = Some(bound.to_owned());
} else if arg != token {
return None;
}
}
file
}
pub fn expand_rule<'a>(
manifest: &Manifest,
base: &Path,
rule: &'a Command,
) -> Result<Vec<Match<'a>>> {
let Some(mac) = parse(&rule.name)? else {
return Ok(vec![Match {
rule,
exp: Expansion {
identity: rule.name.clone(),
file: None,
},
}]);
};
let domain = resolve_domain(manifest, base, rule, &mac.scope)?;
Ok(domain
.into_iter()
.map(|file| Match {
rule,
exp: Expansion {
identity: expand_name(&rule.name, &mac.scope, &file),
file: Some(file),
},
})
.collect())
}
pub fn resolve_matches<'a>(
manifest: &'a Manifest,
base: &Path,
argv: &[String],
) -> Result<Vec<Match<'a>>> {
let mut out = Vec::new();
for rule in &manifest.commands {
if let Some(matched) = match_rule(manifest, base, rule, argv)? {
out.push(matched);
}
}
Ok(out)
}
fn match_rule<'a>(
manifest: &Manifest,
base: &Path,
rule: &'a Command,
argv: &[String],
) -> Result<Option<Match<'a>>> {
let Some(mac) = parse(&rule.name)? else {
return Ok(crate::matcher::matches(rule, argv).then(|| Match {
rule,
exp: Expansion {
identity: rule.name.clone(),
file: None,
},
}));
};
let Some(raw) = bind(&mac, rule, argv) else {
return Ok(None);
};
let norm = normalize(&raw, base);
let domain = resolve_domain(manifest, base, rule, &mac.scope)?;
if !domain.contains(&norm) {
return Ok(None);
}
Ok(Some(Match {
rule,
exp: Expansion {
identity: expand_name(&rule.name, &mac.scope, &norm),
file: Some(norm),
},
}))
}
pub fn detect_collision(matches: &[Match]) -> Result<()> {
for (index, left) in matches.iter().enumerate() {
for right in matches.iter().skip(index + 1) {
if left.exp.identity == right.exp.identity {
return Err(Error::CollidingIdentity {
identity: left.exp.identity.clone(),
rules: format!("`{}`, `{}`", left.rule.name, right.rule.name),
});
}
}
}
Ok(())
}
fn resolve_domain(
manifest: &Manifest,
base: &Path,
rule: &Command,
scope: &str,
) -> Result<Vec<String>> {
let declared = manifest
.scopes
.get(scope)
.ok_or_else(|| Error::UnknownScope {
command: rule.name.clone(),
scope: scope.to_owned(),
})?;
resolve::expand_groups(&[manifest.scope_group(declared, rule)], base)
}
fn normalize(raw: &str, base: &Path) -> String {
let path = Path::new(raw);
let rel = path.strip_prefix(base).unwrap_or(path);
let text = rel.to_string_lossy().replace('\\', "/");
text.strip_prefix("./").unwrap_or(&text).to_owned()
}
#[cfg(test)]
mod tests {
use super::{bind, expand_name, parse};
use crate::manifest::{Command, MatchMode};
fn rule(name: &str, mode: MatchMode) -> Command {
Command {
name: name.to_owned(),
inputs: Vec::new(),
exclude: Vec::new(),
match_mode: mode,
outputs: Vec::new(),
tags: Vec::new(),
on_hit: None,
ttl: None,
}
}
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| (*part).to_owned()).collect()
}
#[test]
fn parse_none_for_static_name() {
assert!(parse("cargo test").expect("parse").is_none());
}
#[test]
fn parse_extracts_scope_and_affixes() {
let mac = parse("cargo mutants -f {targets}")
.expect("parse")
.expect("macro");
assert_eq!(mac.scope, "targets");
assert_eq!(mac.token_index, 3);
assert_eq!(mac.prefix, "");
assert_eq!(mac.suffix, "");
let embedded = parse("ruff --stdin-filename={files} check")
.expect("parse")
.expect("macro");
assert_eq!(embedded.scope, "files");
assert_eq!(embedded.prefix, "--stdin-filename=");
assert_eq!(embedded.suffix, "");
assert_eq!(embedded.token_index, 1);
}
#[test]
fn parse_rejects_malformed_macros() {
assert!(parse("a {b} {c}").is_err(), "two macros");
assert!(parse("a {b").is_err(), "unmatched open");
assert!(parse("a b}").is_err(), "unmatched close");
assert!(parse("a {}").is_err(), "empty macro");
}
#[test]
fn expand_name_substitutes_once() {
assert_eq!(
expand_name("cargo mutants -f {targets}", "targets", "src/a.rs"),
"cargo mutants -f src/a.rs"
);
}
#[test]
fn bind_extracts_the_file_token() {
let mac = parse("cargo mutants -f {targets}")
.expect("parse")
.expect("macro");
let command = rule("cargo mutants -f {targets}", MatchMode::Prefix);
assert_eq!(
bind(
&mac,
&command,
&argv(&["cargo", "mutants", "-f", "src/a.rs"])
),
Some("src/a.rs".to_owned())
);
assert_eq!(
bind(
&mac,
&command,
&argv(&["cargo", "mutants", "-f", "src/a.rs", "--jobs", "4"])
),
Some("src/a.rs".to_owned()),
"prefix mode keeps trailing args"
);
assert_eq!(
bind(&mac, &command, &argv(&["cargo", "build", "-f", "src/a.rs"])),
None,
"literal token mismatch"
);
}
#[test]
fn bind_honours_affixes_and_exact_mode() {
let mac = parse("ruff --file={files}").expect("parse").expect("macro");
let command = rule("ruff --file={files}", MatchMode::Exact);
assert_eq!(
bind(&mac, &command, &argv(&["ruff", "--file=src/a.rs"])),
Some("src/a.rs".to_owned())
);
assert_eq!(
bind(&mac, &command, &argv(&["ruff", "--other=src/a.rs"])),
None,
"prefix must match"
);
assert_eq!(
bind(&mac, &command, &argv(&["ruff", "--file=src/a.rs", "x"])),
None,
"exact mode rejects trailing args"
);
assert_eq!(
bind(&mac, &command, &argv(&["ruff", "--file="])),
None,
"empty binding is not a file"
);
}
}