use core::mem;
use core::ops::Range;
use proc_macro2::{Delimiter, TokenStream, TokenTree};
use super::intent::Intent;
use crate::Result;
use crate::error::Error;
use crate::model::{Channel, Mutant};
use crate::ops::registry::Selection;
use crate::parse::SourceFile;
#[derive(Debug, Clone)]
pub struct Directive {
pub intent: Option<Intent>,
pub selection: Selection,
pub selectors: String,
pub reason: Option<String>,
pub tag: Option<String>,
pub test_timeout_multiplier: Option<f64>,
pub channel: Channel,
pub line: usize,
pub scope: Range<usize>,
}
impl Directive {
#[must_use]
pub fn governs(&self, mutant: &Mutant) -> bool {
mutant.span.start >= self.scope.start && mutant.span.start < self.scope.end && self.selection.contains(&mutant.mutator)
}
}
pub(super) fn build(
intent: Option<Intent>,
arguments: &TokenStream,
channel: Channel,
line: usize,
scope: Range<usize>,
file: &SourceFile,
) -> Result<Directive> {
let parsed = parse_arguments(arguments);
if let Some(err) = parsed.errors.first() {
return Err(Error::new(format!("{}:{line}: {err}", file.path())).usage());
}
if let Some(name) = parsed.unknown.first() {
return Err(Error::new(format!(
"{}:{line}: unknown argument `{name}` in directive, expected `reason`, `tag`, or `test_timeout_multiplier`",
file.path()
))
.usage());
}
let selection = if parsed.selectors.is_empty() {
Selection::everything()
} else {
let mut selection = Selection::empty();
selection
.apply(&parsed.selectors)
.map_err(|error| Error::new(format!("{}:{line}: {error}", file.path())).usage())?;
selection
};
Ok(Directive {
intent,
selection,
selectors: parsed.selectors,
reason: parsed.reason,
tag: parsed.tag,
test_timeout_multiplier: parsed.test_timeout_multiplier,
channel,
line,
scope,
})
}
#[derive(Debug, Default)]
struct Arguments {
selectors: String,
reason: Option<String>,
tag: Option<String>,
test_timeout_multiplier: Option<f64>,
unknown: Vec<String>,
errors: Vec<String>,
}
fn parse_arguments(tokens: &TokenStream) -> Arguments {
let mut arguments = Arguments::default();
let mut selectors: Vec<String> = Vec::new();
let mut current: Vec<TokenTree> = Vec::new();
let mut flush = |current: &mut Vec<TokenTree>, arguments: &mut Arguments| {
if current.is_empty() {
return;
}
let taken = mem::take(current);
if taken.len() >= 3
&& let TokenTree::Ident(name) = &taken[0]
&& let TokenTree::Punct(equals) = &taken[1]
&& equals.as_char() == '='
{
let name = name.to_string();
let value = &taken[2..];
let trailing = match name.as_str() {
"reason" | "tag" => value.len() != 1,
"test_timeout_multiplier" | "timeout_multiplier" | "multiplier" | "factor" => {
value.len() != 1
&& !(value.len() == 2 && matches!(&value[0], TokenTree::Punct(punct) if matches!(punct.as_char(), '+' | '-')))
}
_ => false,
};
if trailing {
arguments.errors.push(format!("trailing tokens after `{name}` value"));
return;
}
if matches!(name.as_str(), "reason" | "tag") && !is_string_literal(value) {
arguments.errors.push(format!("`{name}` must be a string literal"));
return;
}
let rhs = value
.iter()
.map(|token| match token {
TokenTree::Literal(literal) => unquote(&literal.to_string()),
other => other.to_string(),
})
.collect::<String>();
let text = rhs.trim().to_owned();
match name.as_str() {
"reason" => arguments.reason = Some(text),
"tag" => arguments.tag = Some(text),
"test_timeout_multiplier" | "timeout_multiplier" | "multiplier" | "factor" => match text.parse::<f64>() {
Ok(val) => match crate::bounds::factor(&text, val) {
Ok(bounded) => state_multiplier(arguments, bounded),
Err(message) => arguments.errors.push(format!("timeout multiplier {message}")),
},
Err(_cause) => {
arguments
.errors
.push(format!("timeout multiplier must be a positive number, got `{text}`"));
}
},
other => arguments.unknown.push(other.to_owned()),
}
return;
}
let rendered: String = taken
.iter()
.map(|token| match token {
TokenTree::Literal(literal) => unquote(&literal.to_string()),
other => other.to_string(),
})
.collect::<String>();
let cleaned: String = rendered.chars().filter(|character| !character.is_whitespace()).collect();
if !cleaned.is_empty() {
if let Ok(val) = cleaned.parse::<f64>() {
match crate::bounds::factor(&cleaned, val) {
Ok(bounded) => state_multiplier(arguments, bounded),
Err(message) => arguments.errors.push(format!("timeout multiplier {message}")),
}
} else {
selectors.push(cleaned);
}
}
};
for token in tokens.clone() {
match &token {
TokenTree::Punct(punct) if punct.as_char() == ',' => flush(&mut current, &mut arguments),
TokenTree::Group(group) if group.delimiter() == Delimiter::None => {
current.extend(group.stream());
}
_ => current.push(token),
}
}
flush(&mut current, &mut arguments);
arguments.selectors = selectors.join(",");
arguments
}
fn state_multiplier(arguments: &mut Arguments, value: f64) {
if arguments.test_timeout_multiplier.is_some() {
arguments
.errors
.push("a timeout multiplier is stated a second time; only one may apply to an item".to_owned());
return;
}
arguments.test_timeout_multiplier = Some(value);
}
fn is_string_literal(tokens: &[TokenTree]) -> bool {
matches!(tokens, [TokenTree::Literal(literal)] if syn::parse_str::<syn::LitStr>(&literal.to_string()).is_ok())
}
fn unquote(text: &str) -> String {
let body = text.strip_prefix('r').unwrap_or(text);
let hashes = body.len() - body.trim_start_matches('#').len();
let Some(body) = body.get(hashes..body.len().saturating_sub(hashes)) else {
return text.to_owned();
};
body.strip_prefix('"')
.and_then(|inner| inner.strip_suffix('"'))
.map_or_else(|| text.to_owned(), ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use proc_macro2::{Group, Ident, Literal, Punct, Spacing};
use super::super::directives;
use super::*;
fn file(source: &str) -> SourceFile {
SourceFile::parse("test.rs", source.to_owned()).unwrap()
}
#[test]
fn anything_that_is_not_a_string_literal_passes_through_unquote_unchanged() {
for text in ["range", "relational", "r", "r#raw", "arith.add_to_sub", "1", ""] {
assert_eq!(unquote(text), text);
}
}
#[test]
fn a_string_literal_loses_its_delimiters_and_keeps_its_text() {
assert_eq!(unquote("\"why\""), "why");
assert_eq!(unquote("r\"why\""), "why");
assert_eq!(unquote("r#\"say \"no\"\"#"), "say \"no\"");
}
#[test]
fn a_dotted_selector_survives_being_read_as_tokens() {
let source = "#[gamma::skip(arith.add_to_sub)]\nfn f(a: i32) -> i32 { a + 1 }";
let found = directives(&file(source)).unwrap();
assert_eq!(found[0].selectors, "arith.add_to_sub");
}
#[test]
fn a_profile_selector_is_accepted() {
let source = "#[gamma::skip(@arithmetic)]\nfn f(a: i32) -> i32 { a + 1 }";
let found = directives(&file(source)).unwrap();
assert_eq!(found[0].selectors, "@arithmetic");
assert!(found[0].selection.contains("arith.add_to_sub"));
}
#[test]
fn a_negated_selector_is_accepted() {
let source = "#[gamma::skip(arith, !arith.add_to_sub)]\nfn f(a: i32) -> i32 { a + 1 }";
let found = directives(&file(source)).unwrap();
assert!(!found[0].selection.contains("arith.add_to_sub"));
assert!(found[0].selection.contains("arith.mul_to_div"));
}
#[test]
fn a_reason_is_captured() {
let source = "#[gamma::skip(arith, reason = \"fixed point\")]\nfn f(a: i32) -> i32 { a + 1 }";
let found = directives(&file(source)).unwrap();
assert_eq!(found[0].reason.as_deref(), Some("fixed point"));
assert_eq!(found[0].selectors, "arith");
}
#[test]
fn a_tag_is_captured() {
let source = "#[gamma::skip(arith, tag = \"perf\")]\nfn f(a: i32) -> i32 { a + 1 }";
let found = directives(&file(source)).unwrap();
assert_eq!(found[0].tag.as_deref(), Some("perf"));
}
#[test]
fn a_raw_string_reason_keeps_its_text_and_loses_its_delimiters() {
let source = "#[gamma::skip(arith, reason = r#\"say \"no\"\"#)]\nfn f(a: i32) -> i32 { a + 1 }";
let found = directives(&file(source)).unwrap();
assert_eq!(found[0].reason.as_deref(), Some("say \"no\""));
let source = "#[gamma::skip(arith, tag = r\"perf\")]\nfn f(a: i32) -> i32 { a + 1 }";
let found = directives(&file(source)).unwrap();
assert_eq!(found[0].tag.as_deref(), Some("perf"));
}
#[test]
fn a_hash_inside_an_ordinary_string_is_left_alone() {
let source = "#[gamma::skip(arith, reason = \"#1\")]\nfn f(a: i32) -> i32 { a + 1 }";
let found = directives(&file(source)).unwrap();
assert_eq!(found[0].reason.as_deref(), Some("#1"));
}
#[test]
fn an_unknown_named_argument_is_rejected_rather_than_silently_widening_the_directive() {
let source = "#[gamma::skip(op = \"arith\")]\nfn f(a: i32) -> i32 { a + 1 }";
let error = directives(&file(source)).expect_err("an unknown named argument is a usage error");
assert!(error.is_usage(), "{error}");
assert!(error.to_string().contains("unknown argument `op`"), "{error}");
}
#[test]
fn a_misspelled_reason_or_tag_is_rejected_rather_than_losing_its_value() {
for (typo, source) in [
("reasn", "#[gamma::skip(arith, reasn = \"x\")]\nfn f(a: i32) -> i32 { a + 1 }"),
("tga", "#[gamma::skip(arith, tga = \"x\")]\nfn f(a: i32) -> i32 { a + 1 }"),
] {
let error = directives(&file(source)).expect_err("a misspelled named argument is a usage error");
assert!(error.is_usage(), "{typo}: {error}");
assert!(error.to_string().contains(&format!("unknown argument `{typo}`")), "{error}");
}
}
#[test]
fn non_string_comment_metadata_is_rejected_rather_than_widening_a_suppression() {
for (name, value) in [("reason", "performance"), ("tag", "42"), ("reason", "b\"bytes\"")] {
let source = format!("// #[gamma::skip({name} = {value})]\nfn f(a: i32) -> i32 {{ a + 1 }}");
let error = directives(&file(&source)).expect_err("metadata must be a string literal");
assert!(error.is_usage(), "{name} = {value}: {error}");
assert!(
error.to_string().contains(&format!("`{name}` must be a string literal")),
"{name} = {value}: {error}"
);
}
}
#[test]
fn literal_selectors_and_none_delimited_groups_are_rendered() {
let literal = TokenTree::Literal(Literal::string("arith.add_to_sub"));
let comma = TokenTree::Punct(Punct::new(',', Spacing::Alone));
let grouped = TokenTree::Group(Group::new(
Delimiter::None,
TokenStream::from(TokenTree::Ident(Ident::new("literal", proc_macro2::Span::call_site()))),
));
let tokens = [literal, comma, grouped].into_iter().collect();
let arguments = parse_arguments(&tokens);
assert_eq!(arguments.selectors, "arith.add_to_sub,literal");
}
#[test]
fn an_unknown_selector_is_a_hard_error() {
let source = "#[gamma::skip(arith.add_to_multiply)]\nfn f(a: i32) -> i32 { a + 1 }";
let error = directives(&file(source)).unwrap_err();
assert!(error.is_usage());
assert!(error.to_string().contains("add_to_multiply"));
}
#[test]
fn an_unknown_directive_name_is_a_hard_error() {
let source = "// #[gamma::skipp(arith)]\nfn f(a: i32) -> i32 { a + 1 }";
_ = directives(&file(source)).expect_err("the directive was expected to be rejected");
}
#[test]
fn a_malformed_comment_directive_is_a_hard_error() {
let source = "// #[gamma::skip(arith\nfn f(a: i32) -> i32 { a + 1 }";
_ = directives(&file(source)).expect_err("the directive was expected to be rejected");
}
#[test]
fn a_directive_governing_nothing_is_a_hard_error() {
let source = "fn f(a: i32) -> i32 { a + 1 }\n// #[gamma::skip(arith)]\n";
_ = directives(&file(source)).expect_err("the directive was expected to be rejected");
}
}