From c3a780dea00c30e2c479364cf6f71e3090069e23 Mon Sep 17 00:00:00 2001 From: Sander Saares Date: Tue, 14 Apr 2026 10:29:25 +0800 Subject: [PATCH 1/6] Add #[mutants::exclude_re("pattern")] attribute Add a new attribute that excludes specific mutations by regex, without disabling all mutations on the function like #[mutants::skip] does. The attribute can be placed on functions, impl blocks, trait blocks, modules, and files (as an inner attribute). Patterns from outer scopes are inherited. Also supported within cfg_attr. Closes #551 --- NEWS.md | 2 + book/src/attrs.md | 56 +++ mutants_attrs/src/lib.rs | 22 + src/visit.rs | 425 +++++++++++++++++- testdata/exclude_re_attr/Cargo_test.toml | 11 + testdata/exclude_re_attr/src/lib.rs | 147 ++++++ tests/main.rs | 10 + ...util__list_mutants_in_exclude_re_attr.snap | 40 ++ 8 files changed, 711 insertions(+), 2 deletions(-) create mode 100644 testdata/exclude_re_attr/Cargo_test.toml create mode 100644 testdata/exclude_re_attr/src/lib.rs create mode 100644 tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap diff --git a/NEWS.md b/NEWS.md index c467ab85..1c31320f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,6 +4,8 @@ - New: mutate `NonZero` into `1`, and also `-1` when `T` is or may be signed. +- New: `#[mutants::exclude_re("pattern")]` attribute to exclude specific mutations by regex, without disabling all mutations on the function. The attribute can be placed on functions, `impl` blocks, `trait` blocks, modules, and files. Multiple patterns can be applied. Also supported within `cfg_attr`. + ## 27.0.0 Released 2026-03-07. diff --git a/book/src/attrs.md b/book/src/attrs.md index a85b00b1..76c7036f 100644 --- a/book/src/attrs.md +++ b/book/src/attrs.md @@ -49,3 +49,59 @@ mod test { } } ``` + +## Excluding specific mutations with an attribute + +If `#[mutants::skip]` is too broad (it disables _all_ mutations on a function) +you can use `#[mutants::exclude_re("pattern")]` to exclude only mutations +whose name matches a regex, while keeping the rest. + +The regex is matched against the full mutant name (the same string shown by +`cargo mutants --list`), using the same syntax as `--exclude-re` on the command +line. + +For example, to keep all mutations except the "replace with ()" return-value +mutation: + +```rust +#[mutants::exclude_re("with \\(\\)")] +fn do_something(x: i32) -> i32 { + x + 1 +} +``` + +Multiple attributes can be applied to exclude several patterns: + +```rust +#[mutants::exclude_re("with 0")] +#[mutants::exclude_re("with 1")] +fn compute(a: i32, b: i32) -> i32 { + a + b +} +``` + +As with `mutants::skip`, cargo-mutants also looks for `mutants::exclude_re` +within other attributes such as `cfg_attr`, without evaluating the outer +attribute: + +```rust +#[cfg_attr(test, mutants::exclude_re("replace .* -> bool"))] +fn is_valid(&self) -> bool { + // ... + true +} +``` + +### Scope + +`#[mutants::exclude_re]` can be placed on: + +- **Functions** — applies to all mutations within that function. +- **`impl` blocks** — applies to all methods within the block. +- **`trait` blocks** — applies to all default method implementations. +- **`mod` blocks** — applies to all items within the module. +- **Files** (as an inner attribute `#![mutants::exclude_re("...")]`) — applies to the entire file. + +Patterns from outer scopes are inherited: if an `impl` block excludes a pattern, +all methods inside also exclude that pattern, in addition to any patterns on the +methods themselves. diff --git a/mutants_attrs/src/lib.rs b/mutants_attrs/src/lib.rs index eabc3b34..0b46649f 100644 --- a/mutants_attrs/src/lib.rs +++ b/mutants_attrs/src/lib.rs @@ -24,3 +24,25 @@ use proc_macro::TokenStream; pub fn skip(_attr: TokenStream, item: TokenStream) -> TokenStream { item } + +/// Exclude specific mutations matching a regex pattern. +/// +/// Unlike [macro@skip], which skips all mutations on a function, this attribute allows +/// you to exclude only mutations whose name matches the given regex, while keeping +/// other mutations active. +/// +/// This can be applied to functions, impl blocks, trait blocks, modules, etc. +/// +/// ``` +/// #[mutants::exclude_re("delete match arm")] +/// pub fn some_function() -> i32 { +/// // ... +/// # 0 +/// } +/// ``` +/// +/// This is a no-op during compilation, but is seen by cargo-mutants as it processes the source. +#[proc_macro_attribute] +pub fn exclude_re(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} diff --git a/src/visit.rs b/src/visit.rs index 932db260..36192f93 100644 --- a/src/visit.rs +++ b/src/visit.rs @@ -14,9 +14,11 @@ use std::collections::VecDeque; use std::sync::Arc; use std::vec; +use anyhow::anyhow; use camino::{Utf8Path, Utf8PathBuf}; use proc_macro2::{Ident, TokenStream}; use quote::{ToTokens, quote}; +use regex::RegexSet; use syn::ext::IdentExt; use syn::spanned::Spanned; use syn::visit::Visit; @@ -142,6 +144,8 @@ pub fn walk_file( .with_context(|| format!("failed to parse {}", source_file.tree_relative_slashes()))?; let mut visitor = DiscoveryVisitor { error_exprs, + error: None, + exclude_re_stack: Vec::new(), external_mods: Vec::new(), mutants: Vec::new(), mod_namespace_stack: Vec::new(), @@ -151,6 +155,9 @@ pub fn walk_file( options, }; visitor.visit_file(&syn_file); + if let Some(err) = visitor.error { + return Err(err); + } Ok((visitor.mutants, visitor.external_mods)) } @@ -284,6 +291,18 @@ struct DiscoveryVisitor<'o> { error_exprs: &'o [Expr], options: &'o Options, + + /// Stack of compiled `RegexSet`s from `#[mutants::exclude_re("...")]` attributes. + /// + /// Each entry corresponds to a scope (file, mod, impl, trait, fn). + /// When collecting a mutant, all entries in the stack are checked. + exclude_re_stack: Vec, + + /// If set, an error occurred during visiting (e.g. invalid regex in an attribute). + /// + /// Since `Visit` trait methods cannot return errors, we store the error here + /// and propagate it after the visitor finishes. + error: Option, } impl DiscoveryVisitor<'_> { @@ -315,6 +334,40 @@ impl DiscoveryVisitor<'_> { ); } + /// Push `#[mutants::exclude_re("...")]` patterns from the given attributes onto the stack. + /// + /// Returns `true` if successful, `false` if an error was stored (invalid regex). + /// On error, the caller should return early without visiting children. + fn push_exclude_re(&mut self, attrs: &[Attribute]) -> bool { + let patterns = attrs_exclude_re_patterns(attrs); + if patterns.is_empty() { + self.exclude_re_stack.push(RegexSet::empty()); + return true; + } + match RegexSet::new(&patterns) { + Ok(re) => { + self.exclude_re_stack.push(re); + true + } + Err(err) => { + self.error = Some(anyhow!("invalid regex in #[mutants::exclude_re]: {err}")); + false + } + } + } + + fn pop_exclude_re(&mut self) { + self.exclude_re_stack + .pop() + .expect("exclude_re stack should not be empty"); + } + + /// Check whether a mutant name is excluded by any of the currently-active + /// `#[mutants::exclude_re("...")]` attributes on the stack. + fn excluded_by_attr_re(&self, name: &str) -> bool { + self.exclude_re_stack.iter().any(|re| re.is_match(name)) + } + /// Record that we generated some mutants. fn collect_mutant( &mut self, @@ -332,7 +385,12 @@ impl DiscoveryVisitor<'_> { genre, None, ); - if self.options.allows_mutant(&mutant) { + if self.excluded_by_attr_re(&mutant.name) { + trace!( + name = mutant.name(false), + "skip mutant by exclude_re attribute" + ); + } else if self.options.allows_mutant(&mutant) { self.mutants.push(mutant); } else { trace!(name = mutant.name(false), "skip mutant by options"); @@ -428,7 +486,11 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { trace!("file excluded by attrs"); return; } + if !self.push_exclude_re(&i.attrs) { + return; + } syn::visit::visit_file(self, i); + self.pop_exclude_re(); } /// Visit top-level `fn foo()`. @@ -444,10 +506,14 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { if fn_sig_excluded(&i.sig) || attrs_excluded(&i.attrs) || block_is_empty(&i.block) { return; } + if !self.push_exclude_re(&i.attrs) { + return; + } let function = self.enter_function(&i.sig.ident, &i.sig.output, i.span()); self.collect_fn_mutants(&i.sig, &i.block); syn::visit::visit_item_fn(self, i); self.leave_function(function); + self.pop_exclude_re(); } /// Visit `fn foo()` within an `impl`. @@ -468,10 +534,14 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { { return; } + if !self.push_exclude_re(&i.attrs) { + return; + } let function = self.enter_function(&i.sig.ident, &i.sig.output, i.span()); self.collect_fn_mutants(&i.sig, &i.block); syn::visit::visit_impl_item_fn(self, i); self.leave_function(function); + self.pop_exclude_re(); } /// Visit `fn foo() { ... }` within a trait, i.e. a default implementation of a function. @@ -490,10 +560,14 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { if block_is_empty(block) { return; } + if !self.push_exclude_re(&i.attrs) { + return; + } let function = self.enter_function(&i.sig.ident, &i.sig.output, i.span()); self.collect_fn_mutants(&i.sig, block); syn::visit::visit_trait_item_fn(self, i); self.leave_function(function); + self.pop_exclude_re(); } } @@ -502,10 +576,14 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { if attrs_excluded(&i.attrs) { return; } + if !self.push_exclude_re(&i.attrs) { + return; + } let type_name = i.self_ty.to_pretty_string(); let name = if let Some((_, trait_path, _)) = &i.trait_ { if path_ends_with(trait_path, "Default") { // Can't think of how to generate a viable different default. + self.pop_exclude_re(); return; } format!("", trait = trait_path.to_pretty_string()) @@ -513,6 +591,7 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { type_name }; self.in_namespace(&name, |v| syn::visit::visit_item_impl(v, i)); + self.pop_exclude_re(); } /// Visit `trait Foo { ... }` @@ -522,7 +601,11 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { if attrs_excluded(&i.attrs) { return; } + if !self.push_exclude_re(&i.attrs) { + return; + } self.in_namespace(&name, |v| syn::visit::visit_item_trait(v, i)); + self.pop_exclude_re(); } /// Visit `mod foo { ... }` or `mod foo;`. @@ -533,6 +616,9 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { trace!("mod excluded by attrs"); return; } + if !self.push_exclude_re(&node.attrs) { + return; + } let source_location = Span::from(node.span()); @@ -566,6 +652,7 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { } self.in_namespace(&mod_namespace.name, |v| syn::visit::visit_item_mod(v, node)); assert_eq!(self.mod_namespace_stack.pop(), Some(mod_namespace)); + self.pop_exclude_re(); } /// Visit `a op b` expressions. @@ -744,7 +831,9 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { struct_name: struct_name.clone(), }), ); - self.mutants.push(mutant); + if !self.excluded_by_attr_re(&mutant.name) { + self.mutants.push(mutant); + } } } } @@ -942,6 +1031,73 @@ fn attr_is_mutants_skip(attr: &Attribute) -> bool { skip } +/// Extract regex patterns from `#[mutants::exclude_re("...")]` attributes. +/// +/// This also handles `#[cfg_attr(test, mutants::exclude_re("..."))]`. +fn attrs_exclude_re_patterns(attrs: &[Attribute]) -> Vec { + attrs + .iter() + .filter_map(attr_mutants_exclude_re_pattern) + .collect() +} + +/// If this attribute is `#[mutants::exclude_re("pattern")]`, return the pattern string. +/// +/// Also matches `#[cfg_attr(..., mutants::exclude_re("pattern"))]`. +fn attr_mutants_exclude_re_pattern(attr: &Attribute) -> Option { + if path_is(attr.path(), &["mutants", "exclude_re"]) { + return extract_string_from_attr(attr); + } + if !path_is(attr.path(), &["cfg_attr"]) { + return None; + } + // For cfg_attr, we need to find mutants::exclude_re("...") in the token list. + // The tokens look like: `test, mutants::exclude_re("pattern")` + // We use syn to parse the inner attribute. + let tokens = match &attr.meta { + syn::Meta::List(list) => &list.tokens, + _ => return None, + }; + // Wrap the inner content after the condition as an attribute and try to parse it. + // We need to find the portion after the first comma that looks like mutants::exclude_re("...") + let token_str = tokens.to_string(); + // Find "mutants :: exclude_re" (with possible spaces around ::) + // by scanning for the pattern in the token representation. + let normalized = token_str.replace(" :: ", "::"); + let marker = "mutants::exclude_re"; + let start = normalized.find(marker)?; + // Extract from the marker onward in the original token string. + // Find the same position in the original string, accounting for space normalization. + let remaining = &normalized[start + marker.len()..]; + // remaining should start with something like `("pattern")` + let remaining = remaining.trim_start(); + if !remaining.starts_with('(') { + return None; + } + // Find matching close paren, accounting for the string content + let inner = &remaining[1..]; // skip '(' + // Parse the content as a string literal + let close_paren = inner.rfind(')')?; + let literal_str = inner[..close_paren].trim(); + // Parse as a Rust string literal + syn::parse_str::(literal_str) + .ok() + .map(|lit| lit.value()) +} + +/// Extract a string literal argument from an attribute like `#[something("value")]`. +fn extract_string_from_attr(attr: &Attribute) -> Option { + let meta = &attr.meta; + if let syn::Meta::List(list) = meta { + let tokens = &list.tokens; + // Parse the tokens as a single string literal + if let Ok(lit) = syn::parse2::(tokens.clone()) { + return Some(lit.value()); + } + } + None +} + /// Finds the first path attribute (`#[path = "..."]`) /// /// # Errors @@ -1653,4 +1809,269 @@ mod test { ] ); } + + #[test] + fn exclude_re_attr_filters_specific_mutants() { + let options = Options::default(); + let mutants = mutate_source_str( + indoc! {r#" + #[mutants::exclude_re("with \\(\\)")] + fn add(a: i32, b: i32) -> i32 { + a + b + } + "#}, + &options, + ) + .unwrap(); + let names: Vec<&str> = mutants.iter().map(|m| m.name.as_str()).collect(); + // The fn replacement "replace add -> i32 with 0" etc should remain, + // but "replace add -> i32 with ()" should be excluded. + // Also binary operator mutations remain. + assert!( + !names.iter().any(|n| n.contains("with ()")), + "should not contain 'with ()' mutant but got: {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("replace + with")), + "should still contain binary op mutant: {names:?}" + ); + } + + #[test] + fn exclude_re_attr_keeps_all_when_no_match() { + let options = Options::default(); + let mutants_with_attr = mutate_source_str( + indoc! {r#" + #[mutants::exclude_re("this_matches_nothing")] + fn add(a: i32, b: i32) -> i32 { + a + b + } + "#}, + &options, + ) + .unwrap(); + let mutants_without_attr = mutate_source_str( + indoc! {" + fn add(a: i32, b: i32) -> i32 { + a + b + } + "}, + &options, + ) + .unwrap(); + assert_eq!(mutants_with_attr.len(), mutants_without_attr.len()); + } + + #[test] + fn exclude_re_attr_invalid_regex_returns_error() { + let options = Options::default(); + let result = mutate_source_str( + indoc! {r#" + #[mutants::exclude_re("(unclosed")] + fn add(a: i32, b: i32) -> i32 { + a + b + } + "#}, + &options, + ); + assert!(result.is_err(), "invalid regex should produce an error"); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("invalid regex"), + "error should mention 'invalid regex': {err_msg}" + ); + } + + #[test] + fn exclude_re_attr_on_impl_block_applies_to_methods() { + let options = Options::default(); + let mutants = mutate_source_str( + indoc! {r#" + struct Foo; + #[mutants::exclude_re("replace .* -> bool")] + impl Foo { + fn is_ok(&self) -> bool { + true + } + fn count(&self) -> i32 { + 1 + 2 + } + } + "#}, + &options, + ) + .unwrap(); + let names: Vec<&str> = mutants.iter().map(|m| m.name.as_str()).collect(); + // is_ok fn replacement should be excluded, but count fn and binary ops should remain + assert!( + !names.iter().any(|n| n.contains("is_ok")), + "should not contain is_ok mutant but got: {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("count")), + "should contain count mutant: {names:?}" + ); + } + + #[test] + fn cfg_attr_exclude_re_filters_mutants() { + let options = Options::default(); + let mutants = mutate_source_str( + indoc! {r#" + #[cfg_attr(test, mutants::exclude_re("replace .* with 0"))] + fn add(a: i32, b: i32) -> i32 { + a + b + } + "#}, + &options, + ) + .unwrap(); + let names: Vec<&str> = mutants.iter().map(|m| m.name.as_str()).collect(); + assert!( + !names.iter().any(|n| n.contains("with 0")), + "should not contain 'with 0' mutant but got: {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("replace + with")), + "should still contain binary op mutant: {names:?}" + ); + } + + #[test] + fn exclude_re_attr_on_trait_block_applies_to_default_methods() { + let options = Options::default(); + let mutants = mutate_source_str( + indoc! {r#" + #[mutants::exclude_re("replace .* -> bool")] + trait Check { + fn is_ok(&self) -> bool { + true + } + fn count(&self) -> i32 { + 1 + 2 + } + } + "#}, + &options, + ) + .unwrap(); + let names: Vec<&str> = mutants.iter().map(|m| m.name.as_str()).collect(); + assert!( + !names.iter().any(|n| n.contains("is_ok")), + "should not contain is_ok mutant but got: {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("count")), + "should contain count mutant: {names:?}" + ); + } + + #[test] + fn exclude_re_attr_on_mod_block_applies_to_functions() { + let options = Options::default(); + let mutants = mutate_source_str( + indoc! {r#" + #[mutants::exclude_re("replace .* -> bool")] + mod inner { + pub fn is_ok() -> bool { + true + } + pub fn count() -> i32 { + 1 + 2 + } + } + "#}, + &options, + ) + .unwrap(); + let names: Vec<&str> = mutants.iter().map(|m| m.name.as_str()).collect(); + assert!( + !names.iter().any(|n| n.contains("is_ok")), + "should not contain is_ok mutant but got: {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("count")), + "should contain count mutant: {names:?}" + ); + } + + #[test] + fn exclude_re_attr_inner_file_attribute_applies_to_all_functions() { + let options = Options::default(); + let mutants = mutate_source_str( + indoc! {r#" + #![mutants::exclude_re("replace .* -> bool")] + + fn is_ok() -> bool { + true + } + fn count() -> i32 { + 1 + 2 + } + "#}, + &options, + ) + .unwrap(); + let names: Vec<&str> = mutants.iter().map(|m| m.name.as_str()).collect(); + assert!( + !names.iter().any(|n| n.contains("is_ok")), + "should not contain is_ok mutant but got: {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("count")), + "should contain count mutant: {names:?}" + ); + } + + #[test] + fn exclude_re_attr_inherits_from_outer_scope() { + let options = Options::default(); + let mutants = mutate_source_str( + indoc! {r#" + struct Foo; + #[mutants::exclude_re("replace .* -> bool")] + impl Foo { + #[mutants::exclude_re("replace .* -> i32")] + fn count(&self) -> i32 { + 1 + 2 + } + fn is_ok(&self) -> bool { + true + } + fn add(&self, a: i32, b: i32) -> i32 { + a + b + } + } + "#}, + &options, + ) + .unwrap(); + let names: Vec<&str> = mutants.iter().map(|m| m.name.as_str()).collect(); + // is_ok: excluded by impl-level pattern (-> bool) + assert!( + !names.iter().any(|n| n.contains("is_ok")), + "should not contain is_ok mutant but got: {names:?}" + ); + // count: fn-value excluded by fn-level pattern (-> i32), but also + // impl-level pattern (-> bool) doesn't affect it; binary ops remain + assert!( + !names + .iter() + .any(|n| n.contains("replace count") || n.contains("replace Foo::count")), + "should not contain count fn-value mutant but got: {names:?}" + ); + assert!( + names + .iter() + .any(|n| n.contains("replace + with") && n.contains("count")), + "should contain binary op mutant in count: {names:?}" + ); + // add: fn-value NOT excluded (impl pattern only covers bool), binary ops remain + assert!( + names + .iter() + .any(|n| n.contains("add") && n.contains("-> i32")), + "should still contain add fn-value mutant: {names:?}" + ); + } } diff --git a/testdata/exclude_re_attr/Cargo_test.toml b/testdata/exclude_re_attr/Cargo_test.toml new file mode 100644 index 00000000..ce906318 --- /dev/null +++ b/testdata/exclude_re_attr/Cargo_test.toml @@ -0,0 +1,11 @@ +[package] +name = "cargo-mutants-testdata-exclude-re-attr" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +doctest = false + +[dependencies] +mutants = { path = "../../mutants_attrs" } diff --git a/testdata/exclude_re_attr/src/lib.rs b/testdata/exclude_re_attr/src/lib.rs new file mode 100644 index 00000000..c1be1467 --- /dev/null +++ b/testdata/exclude_re_attr/src/lib.rs @@ -0,0 +1,147 @@ +//! Test tree for `#[mutants::exclude_re("...")]` attribute. +//! +//! This tests that specific mutations can be excluded by regex while +//! keeping other mutations active on the same function. + +/// This function has an exclude_re that filters out the "replace with ()" mutation +/// but keeps binary operator mutations. +#[mutants::exclude_re("replace .* with ()")] +pub fn add_numbers(a: i32, b: i32) -> i32 { + a + b +} + +/// This function has no exclude_re, so all mutations should be generated. +pub fn multiply(a: i32, b: i32) -> i32 { + a * b +} + +/// This function uses cfg_attr form of exclude_re. +#[cfg_attr(test, mutants::exclude_re("replace .* with"))] +pub fn subtract(a: i32, b: i32) -> i32 { + a - b +} + +/// This function has an exclude_re that filters out binary operator mutations. +#[mutants::exclude_re("replace [+] with")] +pub fn add_one(a: i32) -> i32 { + a + 1 +} + +pub struct Calculator; + +/// exclude_re on an impl block applies to all methods inside. +#[mutants::exclude_re("replace .* -> bool")] +impl Calculator { + pub fn is_positive(x: i32) -> bool { + x > 0 + } + + pub fn double(x: i32) -> i32 { + x + x + } +} + +/// exclude_re on a trait block applies to all default method implementations. +#[mutants::exclude_re("replace .* -> bool")] +pub trait Checker { + fn is_valid(&self) -> bool { + true + } + + fn score(&self) -> i32 { + 1 + 2 + } +} + +/// exclude_re on a mod block applies to all functions inside. +#[mutants::exclude_re("replace .* -> bool")] +mod predicates { + pub fn always_true() -> bool { + true + } + + pub fn increment(x: i32) -> i32 { + x + 1 + } +} + +/// exclude_re on an impl block is inherited by methods; methods can add their own. +pub struct Combo; + +#[mutants::exclude_re("replace .* -> bool")] +impl Combo { + /// This method adds its own exclude_re on top of the impl-level one. + #[mutants::exclude_re("replace .* -> i32")] + pub fn count(&self) -> i32 { + 1 + 2 + } + + pub fn is_ok(&self) -> bool { + true + } + + /// This method is not excluded by the impl-level pattern (returns i32, not bool). + pub fn add(&self, a: i32, b: i32) -> i32 { + a + b + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_numbers() { + assert_eq!(add_numbers(2, 3), 5); + } + + #[test] + fn test_multiply() { + assert_eq!(multiply(2, 3), 6); + } + + #[test] + fn test_subtract() { + assert_eq!(subtract(5, 3), 2); + } + + #[test] + fn test_add_one() { + assert_eq!(add_one(5), 6); + } + + #[test] + fn test_is_positive() { + assert!(Calculator::is_positive(1)); + assert!(!Calculator::is_positive(-1)); + } + + #[test] + fn test_double() { + assert_eq!(Calculator::double(3), 6); + } + + struct MyChecker; + impl Checker for MyChecker {} + + #[test] + fn test_checker_defaults() { + let c = MyChecker; + assert!(c.is_valid()); + assert_eq!(c.score(), 3); + } + + #[test] + fn test_predicates() { + assert!(predicates::always_true()); + assert_eq!(predicates::increment(5), 6); + } + + #[test] + fn test_combo() { + let c = Combo; + assert_eq!(c.count(), 3); + assert!(c.is_ok()); + assert_eq!(c.add(2, 3), 5); + } +} diff --git a/tests/main.rs b/tests/main.rs index 322fe7b0..4edb0812 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -3181,6 +3181,16 @@ fn list_mutants_in_cfg_attr_test_skip_json() { .assert_insta("list_mutants_in_cfg_attr_test_skip_json"); } +#[test] +fn list_mutants_in_exclude_re_attr() { + let tmp_src_dir = copy_of_testdata("exclude_re_attr"); + run() + .arg("mutants") + .arg("--list") + .current_dir(tmp_src_dir.path()) + .assert_insta("list_mutants_in_exclude_re_attr"); +} + #[test] fn list_mutants_with_dir_option() { let temp = copy_of_testdata("factorial"); diff --git a/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap b/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap new file mode 100644 index 00000000..913febe9 --- /dev/null +++ b/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap @@ -0,0 +1,40 @@ +--- +source: tests/util/mod.rs +assertion_line: 33 +expression: "String::from_utf8_lossy(&output.stdout)" + +--- +src/lib.rs:15:5: replace multiply -> i32 with 0 +src/lib.rs:15:5: replace multiply -> i32 with 1 +src/lib.rs:15:5: replace multiply -> i32 with -1 +src/lib.rs:15:7: replace * with + in multiply +src/lib.rs:15:7: replace * with / in multiply +src/lib.rs:27:5: replace add_one -> i32 with 0 +src/lib.rs:27:5: replace add_one -> i32 with 1 +src/lib.rs:27:5: replace add_one -> i32 with -1 +src/lib.rs:36:11: replace > with == in Calculator::is_positive +src/lib.rs:36:11: replace > with < in Calculator::is_positive +src/lib.rs:36:11: replace > with >= in Calculator::is_positive +src/lib.rs:40:9: replace Calculator::double -> i32 with 0 +src/lib.rs:40:9: replace Calculator::double -> i32 with 1 +src/lib.rs:40:9: replace Calculator::double -> i32 with -1 +src/lib.rs:40:11: replace + with - in Calculator::double +src/lib.rs:40:11: replace + with * in Calculator::double +src/lib.rs:52:9: replace Checker::score -> i32 with 0 +src/lib.rs:52:9: replace Checker::score -> i32 with 1 +src/lib.rs:52:9: replace Checker::score -> i32 with -1 +src/lib.rs:52:11: replace + with - in Checker::score +src/lib.rs:52:11: replace + with * in Checker::score +src/lib.rs:64:9: replace predicates::increment -> i32 with 0 +src/lib.rs:64:9: replace predicates::increment -> i32 with 1 +src/lib.rs:64:9: replace predicates::increment -> i32 with -1 +src/lib.rs:64:11: replace + with - in predicates::increment +src/lib.rs:64:11: replace + with * in predicates::increment +src/lib.rs:76:11: replace + with - in Combo::count +src/lib.rs:76:11: replace + with * in Combo::count +src/lib.rs:85:9: replace Combo::add -> i32 with 0 +src/lib.rs:85:9: replace Combo::add -> i32 with 1 +src/lib.rs:85:9: replace Combo::add -> i32 with -1 +src/lib.rs:85:11: replace + with - in Combo::add +src/lib.rs:85:11: replace + with * in Combo::add + From dd7c79b521beca2d847f8ee4f6d948baa286ac61 Mon Sep 17 00:00:00 2001 From: Sander Saares Date: Tue, 14 Apr 2026 10:32:14 +0800 Subject: [PATCH 2/6] Use raw string literal in exclude_re doc example --- book/src/attrs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/book/src/attrs.md b/book/src/attrs.md index 76c7036f..ca8bda4e 100644 --- a/book/src/attrs.md +++ b/book/src/attrs.md @@ -64,7 +64,7 @@ For example, to keep all mutations except the "replace with ()" return-value mutation: ```rust -#[mutants::exclude_re("with \\(\\)")] +#[mutants::exclude_re(r"with \(\)")] fn do_something(x: i32) -> i32 { x + 1 } From aafb60f802f598ee1163adaab964afc370d2dd4e Mon Sep 17 00:00:00 2001 From: Sander Saares Date: Tue, 14 Apr 2026 10:34:11 +0800 Subject: [PATCH 3/6] Preserve first error in push_exclude_re --- src/visit.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/visit.rs b/src/visit.rs index 36192f93..9e28dfd4 100644 --- a/src/visit.rs +++ b/src/visit.rs @@ -350,7 +350,9 @@ impl DiscoveryVisitor<'_> { true } Err(err) => { - self.error = Some(anyhow!("invalid regex in #[mutants::exclude_re]: {err}")); + self.error.get_or_insert(anyhow!( + "invalid regex in #[mutants::exclude_re]: {err}" + )); false } } From 94ea2816abfca29e3c8f69befa05e083880d3c2e Mon Sep 17 00:00:00 2001 From: Sander Saares Date: Tue, 14 Apr 2026 10:40:36 +0800 Subject: [PATCH 4/6] Fix testdata regexes to match their comments add_numbers used 'replace .* with ()' where () was a regex capture group matching empty string, causing it to exclude ALL mutations instead of just 'with ()'. Use r"with \(\)" instead. subtract used 'replace .* with' which excluded everything. Use 'with 0' to demonstrate cfg_attr while keeping other mutations. --- testdata/exclude_re_attr/src/lib.rs | 6 +++--- .../main__util__list_mutants_in_exclude_re_attr.snap | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/testdata/exclude_re_attr/src/lib.rs b/testdata/exclude_re_attr/src/lib.rs index c1be1467..4e4435dd 100644 --- a/testdata/exclude_re_attr/src/lib.rs +++ b/testdata/exclude_re_attr/src/lib.rs @@ -5,7 +5,7 @@ /// This function has an exclude_re that filters out the "replace with ()" mutation /// but keeps binary operator mutations. -#[mutants::exclude_re("replace .* with ()")] +#[mutants::exclude_re(r"with \(\)")] pub fn add_numbers(a: i32, b: i32) -> i32 { a + b } @@ -15,8 +15,8 @@ pub fn multiply(a: i32, b: i32) -> i32 { a * b } -/// This function uses cfg_attr form of exclude_re. -#[cfg_attr(test, mutants::exclude_re("replace .* with"))] +/// This function uses cfg_attr form of exclude_re, filtering out "with 0" mutations. +#[cfg_attr(test, mutants::exclude_re("with 0"))] pub fn subtract(a: i32, b: i32) -> i32 { a - b } diff --git a/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap b/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap index 913febe9..5d923331 100644 --- a/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap +++ b/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap @@ -4,11 +4,20 @@ assertion_line: 33 expression: "String::from_utf8_lossy(&output.stdout)" --- +src/lib.rs:10:5: replace add_numbers -> i32 with 0 +src/lib.rs:10:5: replace add_numbers -> i32 with 1 +src/lib.rs:10:5: replace add_numbers -> i32 with -1 +src/lib.rs:10:7: replace + with - in add_numbers +src/lib.rs:10:7: replace + with * in add_numbers src/lib.rs:15:5: replace multiply -> i32 with 0 src/lib.rs:15:5: replace multiply -> i32 with 1 src/lib.rs:15:5: replace multiply -> i32 with -1 src/lib.rs:15:7: replace * with + in multiply src/lib.rs:15:7: replace * with / in multiply +src/lib.rs:21:5: replace subtract -> i32 with 1 +src/lib.rs:21:5: replace subtract -> i32 with -1 +src/lib.rs:21:7: replace - with + in subtract +src/lib.rs:21:7: replace - with / in subtract src/lib.rs:27:5: replace add_one -> i32 with 0 src/lib.rs:27:5: replace add_one -> i32 with 1 src/lib.rs:27:5: replace add_one -> i32 with -1 From cc8c95549289381ea7d3b37dcbd7bb5df036f1f8 Mon Sep 17 00:00:00 2001 From: Sander Saares Date: Tue, 14 Apr 2026 10:42:48 +0800 Subject: [PATCH 5/6] Add inline comments listing filtered mutations in testdata --- testdata/exclude_re_attr/src/lib.rs | 8 ++ ...util__list_mutants_in_exclude_re_attr.snap | 84 +++++++++---------- 2 files changed, 50 insertions(+), 42 deletions(-) diff --git a/testdata/exclude_re_attr/src/lib.rs b/testdata/exclude_re_attr/src/lib.rs index 4e4435dd..9bdfa547 100644 --- a/testdata/exclude_re_attr/src/lib.rs +++ b/testdata/exclude_re_attr/src/lib.rs @@ -5,6 +5,7 @@ /// This function has an exclude_re that filters out the "replace with ()" mutation /// but keeps binary operator mutations. +/// Filtered: "replace add_numbers -> i32 with ()" #[mutants::exclude_re(r"with \(\)")] pub fn add_numbers(a: i32, b: i32) -> i32 { a + b @@ -16,12 +17,14 @@ pub fn multiply(a: i32, b: i32) -> i32 { } /// This function uses cfg_attr form of exclude_re, filtering out "with 0" mutations. +/// Filtered: "replace subtract -> i32 with 0" #[cfg_attr(test, mutants::exclude_re("with 0"))] pub fn subtract(a: i32, b: i32) -> i32 { a - b } /// This function has an exclude_re that filters out binary operator mutations. +/// Filtered: "replace + with - in add_one", "replace + with * in add_one" #[mutants::exclude_re("replace [+] with")] pub fn add_one(a: i32) -> i32 { a + 1 @@ -30,6 +33,7 @@ pub fn add_one(a: i32) -> i32 { pub struct Calculator; /// exclude_re on an impl block applies to all methods inside. +/// Filtered: "replace Calculator::is_positive -> bool with true/false" #[mutants::exclude_re("replace .* -> bool")] impl Calculator { pub fn is_positive(x: i32) -> bool { @@ -42,6 +46,7 @@ impl Calculator { } /// exclude_re on a trait block applies to all default method implementations. +/// Filtered: "replace Checker::is_valid -> bool with true/false" #[mutants::exclude_re("replace .* -> bool")] pub trait Checker { fn is_valid(&self) -> bool { @@ -54,6 +59,7 @@ pub trait Checker { } /// exclude_re on a mod block applies to all functions inside. +/// Filtered: "replace predicates::always_true -> bool with true/false" #[mutants::exclude_re("replace .* -> bool")] mod predicates { pub fn always_true() -> bool { @@ -71,6 +77,8 @@ pub struct Combo; #[mutants::exclude_re("replace .* -> bool")] impl Combo { /// This method adds its own exclude_re on top of the impl-level one. + /// Filtered by impl: "replace Combo::count -> bool ..." (N/A, returns i32) + /// Filtered by method: "replace Combo::count -> i32 with 0/1/-1" #[mutants::exclude_re("replace .* -> i32")] pub fn count(&self) -> i32 { 1 + 2 diff --git a/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap b/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap index 5d923331..8e71b398 100644 --- a/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap +++ b/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap @@ -4,46 +4,46 @@ assertion_line: 33 expression: "String::from_utf8_lossy(&output.stdout)" --- -src/lib.rs:10:5: replace add_numbers -> i32 with 0 -src/lib.rs:10:5: replace add_numbers -> i32 with 1 -src/lib.rs:10:5: replace add_numbers -> i32 with -1 -src/lib.rs:10:7: replace + with - in add_numbers -src/lib.rs:10:7: replace + with * in add_numbers -src/lib.rs:15:5: replace multiply -> i32 with 0 -src/lib.rs:15:5: replace multiply -> i32 with 1 -src/lib.rs:15:5: replace multiply -> i32 with -1 -src/lib.rs:15:7: replace * with + in multiply -src/lib.rs:15:7: replace * with / in multiply -src/lib.rs:21:5: replace subtract -> i32 with 1 -src/lib.rs:21:5: replace subtract -> i32 with -1 -src/lib.rs:21:7: replace - with + in subtract -src/lib.rs:21:7: replace - with / in subtract -src/lib.rs:27:5: replace add_one -> i32 with 0 -src/lib.rs:27:5: replace add_one -> i32 with 1 -src/lib.rs:27:5: replace add_one -> i32 with -1 -src/lib.rs:36:11: replace > with == in Calculator::is_positive -src/lib.rs:36:11: replace > with < in Calculator::is_positive -src/lib.rs:36:11: replace > with >= in Calculator::is_positive -src/lib.rs:40:9: replace Calculator::double -> i32 with 0 -src/lib.rs:40:9: replace Calculator::double -> i32 with 1 -src/lib.rs:40:9: replace Calculator::double -> i32 with -1 -src/lib.rs:40:11: replace + with - in Calculator::double -src/lib.rs:40:11: replace + with * in Calculator::double -src/lib.rs:52:9: replace Checker::score -> i32 with 0 -src/lib.rs:52:9: replace Checker::score -> i32 with 1 -src/lib.rs:52:9: replace Checker::score -> i32 with -1 -src/lib.rs:52:11: replace + with - in Checker::score -src/lib.rs:52:11: replace + with * in Checker::score -src/lib.rs:64:9: replace predicates::increment -> i32 with 0 -src/lib.rs:64:9: replace predicates::increment -> i32 with 1 -src/lib.rs:64:9: replace predicates::increment -> i32 with -1 -src/lib.rs:64:11: replace + with - in predicates::increment -src/lib.rs:64:11: replace + with * in predicates::increment -src/lib.rs:76:11: replace + with - in Combo::count -src/lib.rs:76:11: replace + with * in Combo::count -src/lib.rs:85:9: replace Combo::add -> i32 with 0 -src/lib.rs:85:9: replace Combo::add -> i32 with 1 -src/lib.rs:85:9: replace Combo::add -> i32 with -1 -src/lib.rs:85:11: replace + with - in Combo::add -src/lib.rs:85:11: replace + with * in Combo::add +src/lib.rs:11:5: replace add_numbers -> i32 with 0 +src/lib.rs:11:5: replace add_numbers -> i32 with 1 +src/lib.rs:11:5: replace add_numbers -> i32 with -1 +src/lib.rs:11:7: replace + with - in add_numbers +src/lib.rs:11:7: replace + with * in add_numbers +src/lib.rs:16:5: replace multiply -> i32 with 0 +src/lib.rs:16:5: replace multiply -> i32 with 1 +src/lib.rs:16:5: replace multiply -> i32 with -1 +src/lib.rs:16:7: replace * with + in multiply +src/lib.rs:16:7: replace * with / in multiply +src/lib.rs:23:5: replace subtract -> i32 with 1 +src/lib.rs:23:5: replace subtract -> i32 with -1 +src/lib.rs:23:7: replace - with + in subtract +src/lib.rs:23:7: replace - with / in subtract +src/lib.rs:30:5: replace add_one -> i32 with 0 +src/lib.rs:30:5: replace add_one -> i32 with 1 +src/lib.rs:30:5: replace add_one -> i32 with -1 +src/lib.rs:40:11: replace > with == in Calculator::is_positive +src/lib.rs:40:11: replace > with < in Calculator::is_positive +src/lib.rs:40:11: replace > with >= in Calculator::is_positive +src/lib.rs:44:9: replace Calculator::double -> i32 with 0 +src/lib.rs:44:9: replace Calculator::double -> i32 with 1 +src/lib.rs:44:9: replace Calculator::double -> i32 with -1 +src/lib.rs:44:11: replace + with - in Calculator::double +src/lib.rs:44:11: replace + with * in Calculator::double +src/lib.rs:57:9: replace Checker::score -> i32 with 0 +src/lib.rs:57:9: replace Checker::score -> i32 with 1 +src/lib.rs:57:9: replace Checker::score -> i32 with -1 +src/lib.rs:57:11: replace + with - in Checker::score +src/lib.rs:57:11: replace + with * in Checker::score +src/lib.rs:70:9: replace predicates::increment -> i32 with 0 +src/lib.rs:70:9: replace predicates::increment -> i32 with 1 +src/lib.rs:70:9: replace predicates::increment -> i32 with -1 +src/lib.rs:70:11: replace + with - in predicates::increment +src/lib.rs:70:11: replace + with * in predicates::increment +src/lib.rs:84:11: replace + with - in Combo::count +src/lib.rs:84:11: replace + with * in Combo::count +src/lib.rs:93:9: replace Combo::add -> i32 with 0 +src/lib.rs:93:9: replace Combo::add -> i32 with 1 +src/lib.rs:93:9: replace Combo::add -> i32 with -1 +src/lib.rs:93:11: replace + with - in Combo::add +src/lib.rs:93:11: replace + with * in Combo::add From d0314a9c618f1043cf707f7fac1fe4cc17da0337 Mon Sep 17 00:00:00 2001 From: Sander Saares Date: Mon, 11 May 2026 15:11:36 +0300 Subject: [PATCH 6/6] Address PR #607 review feedback - mutants_attrs: bump version 0.0.4 -> 0.0.5 for the new exclude_re attribute. - visit.rs: replace TokenStream::to_string() + substring scan in attr_mutants_exclude_re_pattern with AST-based parsing using Attribute::parse_args_with and Attribute::parse_nested_meta, mirroring attr_is_mutants_skip. Handles multiple exclude_re attrs in one cfg_attr, arbitrary whitespace, and other token shapes correctly. - visit.rs: malformed #[mutants::exclude_re] (missing arg, multiple args, non-string arg) is now a hard error stored on visitor.error, matching the existing invalid-regex behaviour. Previously these were silently no-ops, giving users a false sense of safety. - visit.rs: invalid-regex error now includes the source file/line of the offending attribute and the offending pattern, e.g. "src/main.rs:3:1: invalid regex in #[mutants::exclude_re(\"(unclosed\")]: ...". - visit.rs: introduce in_exclude_re_scope closure helper that guarantees pop_exclude_re runs even if the visited body returns early. visit_item_mod previously left the exclude_re stack unbalanced on its invalid-#[path] early return; this also covers future early-return paths in visit_item_impl and friends. - Tests: rewrite exclude_re_attr_filters_specific_mutants to actually exercise filtering on an i32 function (exclude "with 0", assert "with 1"/"with -1"/binop survive) - previously the regex with \(\) was a no-op on i32, so the test passed even if filtering was broken. - Tests: strengthen exclude_re_attr_keeps_all_when_no_match to compare position-independent mutant names, not just lengths. - Tests: add regression tests for malformed attribute forms and for the source-location in the error message. - testdata/exclude_re_attr & book/src/attrs.md: switch the misleading with \(\) example to with 0 so it actually demonstrates the attribute working on an i32 function. Regenerate the integration snapshot, which now visibly shows with 0 filtered out of add_numbers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 2 +- book/src/attrs.md | 6 +- mutants_attrs/Cargo.toml | 2 +- src/visit.rs | 496 ++++++++++++------ testdata/exclude_re_attr/src/lib.rs | 8 +- ...util__list_mutants_in_exclude_re_attr.snap | 1 - 6 files changed, 350 insertions(+), 165 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50c6f1d9..edf7eadd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -784,7 +784,7 @@ checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126" [[package]] name = "mutants" -version = "0.0.4" +version = "0.0.5" [[package]] name = "nextest-metadata" diff --git a/book/src/attrs.md b/book/src/attrs.md index ca8bda4e..6ab43ce7 100644 --- a/book/src/attrs.md +++ b/book/src/attrs.md @@ -60,11 +60,11 @@ The regex is matched against the full mutant name (the same string shown by `cargo mutants --list`), using the same syntax as `--exclude-re` on the command line. -For example, to keep all mutations except the "replace with ()" return-value -mutation: +For example, to keep all mutations on an `i32`-returning function except the +"replace ... -> i32 with 0" return-value mutation: ```rust -#[mutants::exclude_re(r"with \(\)")] +#[mutants::exclude_re("with 0")] fn do_something(x: i32) -> i32 { x + 1 } diff --git a/mutants_attrs/Cargo.toml b/mutants_attrs/Cargo.toml index b3a72842..639ad201 100644 --- a/mutants_attrs/Cargo.toml +++ b/mutants_attrs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mutants" -version = "0.0.4" +version = "0.0.5" edition = "2018" license = "MIT" description = "Decorator attributes to be used with cargo-mutants" diff --git a/src/visit.rs b/src/visit.rs index 9e28dfd4..1d2a91fa 100644 --- a/src/visit.rs +++ b/src/visit.rs @@ -31,7 +31,7 @@ use crate::mutant::{Function, MutationTarget}; use crate::package::Package; use crate::pretty::ToPrettyString; use crate::source::SourceFile; -use crate::span::Span; +use crate::span::{LineColumn, Span}; use crate::{Console, Context, Genre, Mutant, Options, Result, check_interrupted}; /// Mutants and files discovered in a source tree. @@ -336,10 +336,25 @@ impl DiscoveryVisitor<'_> { /// Push `#[mutants::exclude_re("...")]` patterns from the given attributes onto the stack. /// - /// Returns `true` if successful, `false` if an error was stored (invalid regex). - /// On error, the caller should return early without visiting children. + /// Returns `true` if successful, `false` if an error was stored on the + /// visitor (invalid regex or malformed attribute). On error the caller + /// must not visit children and must not call [`Self::pop_exclude_re`], + /// because nothing was pushed. + /// + /// Prefer [`Self::in_exclude_re_scope`] over calling push/pop directly so + /// that early returns can't leave the stack unbalanced. fn push_exclude_re(&mut self, attrs: &[Attribute]) -> bool { - let patterns = attrs_exclude_re_patterns(attrs); + let patterns = match attrs_exclude_re_patterns(attrs) { + Ok(patterns) => patterns, + Err((span, msg)) => { + let location = self + .source_file + .format_source_location(LineColumn::from(span.start())); + self.error + .get_or_insert_with(|| anyhow!("{location}: {msg}")); + return false; + } + }; if patterns.is_empty() { self.exclude_re_stack.push(RegexSet::empty()); return true; @@ -350,20 +365,60 @@ impl DiscoveryVisitor<'_> { true } Err(err) => { - self.error.get_or_insert(anyhow!( - "invalid regex in #[mutants::exclude_re]: {err}" - )); + let location = attrs + .iter() + .find(|a| { + path_is(a.path(), &["mutants", "exclude_re"]) + || path_is(a.path(), &["cfg_attr"]) + }) + .map_or_else( + || self.source_file.tree_relative_slashes(), + |a| { + self.source_file + .format_source_location(LineColumn::from(a.span().start())) + }, + ); + let quoted = patterns + .iter() + .map(|p| format!("\"{p}\"")) + .collect::>() + .join(", "); + self.error.get_or_insert_with(|| { + anyhow!("{location}: invalid regex in #[mutants::exclude_re({quoted})]: {err}") + }); false } } } + /// Pop the most recent `exclude_re` scope. + /// + /// Callers should normally use [`Self::in_exclude_re_scope`] instead of + /// calling [`Self::push_exclude_re`] and this directly, so that an early + /// return inside the scope can't leave the stack unbalanced. fn pop_exclude_re(&mut self) { self.exclude_re_stack .pop() .expect("exclude_re stack should not be empty"); } + /// Run `f` inside a new `#[mutants::exclude_re("...")]` scope derived from `attrs`. + /// + /// On any kind of failure (invalid regex, malformed attribute), the error + /// is stored on the visitor and `f` is not invoked. The scope is always + /// popped after `f` returns, so callers don't need to worry about early + /// returns inside `f` leaving the stack unbalanced. + fn in_exclude_re_scope(&mut self, attrs: &[Attribute], f: F) + where + F: FnOnce(&mut Self), + { + if !self.push_exclude_re(attrs) { + return; + } + f(self); + self.pop_exclude_re(); + } + /// Check whether a mutant name is excluded by any of the currently-active /// `#[mutants::exclude_re("...")]` attributes on the stack. fn excluded_by_attr_re(&self, name: &str) -> bool { @@ -488,11 +543,9 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { trace!("file excluded by attrs"); return; } - if !self.push_exclude_re(&i.attrs) { - return; - } - syn::visit::visit_file(self, i); - self.pop_exclude_re(); + self.in_exclude_re_scope(&i.attrs, |v| { + syn::visit::visit_file(v, i); + }); } /// Visit top-level `fn foo()`. @@ -508,14 +561,12 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { if fn_sig_excluded(&i.sig) || attrs_excluded(&i.attrs) || block_is_empty(&i.block) { return; } - if !self.push_exclude_re(&i.attrs) { - return; - } - let function = self.enter_function(&i.sig.ident, &i.sig.output, i.span()); - self.collect_fn_mutants(&i.sig, &i.block); - syn::visit::visit_item_fn(self, i); - self.leave_function(function); - self.pop_exclude_re(); + self.in_exclude_re_scope(&i.attrs, |v| { + let function = v.enter_function(&i.sig.ident, &i.sig.output, i.span()); + v.collect_fn_mutants(&i.sig, &i.block); + syn::visit::visit_item_fn(v, i); + v.leave_function(function); + }); } /// Visit `fn foo()` within an `impl`. @@ -536,14 +587,12 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { { return; } - if !self.push_exclude_re(&i.attrs) { - return; - } - let function = self.enter_function(&i.sig.ident, &i.sig.output, i.span()); - self.collect_fn_mutants(&i.sig, &i.block); - syn::visit::visit_impl_item_fn(self, i); - self.leave_function(function); - self.pop_exclude_re(); + self.in_exclude_re_scope(&i.attrs, |v| { + let function = v.enter_function(&i.sig.ident, &i.sig.output, i.span()); + v.collect_fn_mutants(&i.sig, &i.block); + syn::visit::visit_impl_item_fn(v, i); + v.leave_function(function); + }); } /// Visit `fn foo() { ... }` within a trait, i.e. a default implementation of a function. @@ -562,14 +611,12 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { if block_is_empty(block) { return; } - if !self.push_exclude_re(&i.attrs) { - return; - } - let function = self.enter_function(&i.sig.ident, &i.sig.output, i.span()); - self.collect_fn_mutants(&i.sig, block); - syn::visit::visit_trait_item_fn(self, i); - self.leave_function(function); - self.pop_exclude_re(); + self.in_exclude_re_scope(&i.attrs, |v| { + let function = v.enter_function(&i.sig.ident, &i.sig.output, i.span()); + v.collect_fn_mutants(&i.sig, block); + syn::visit::visit_trait_item_fn(v, i); + v.leave_function(function); + }); } } @@ -578,22 +625,22 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { if attrs_excluded(&i.attrs) { return; } - if !self.push_exclude_re(&i.attrs) { - return; - } - let type_name = i.self_ty.to_pretty_string(); - let name = if let Some((_, trait_path, _)) = &i.trait_ { - if path_ends_with(trait_path, "Default") { - // Can't think of how to generate a viable different default. - self.pop_exclude_re(); - return; - } - format!("", trait = trait_path.to_pretty_string()) - } else { - type_name - }; - self.in_namespace(&name, |v| syn::visit::visit_item_impl(v, i)); - self.pop_exclude_re(); + self.in_exclude_re_scope(&i.attrs, |v| { + let type_name = i.self_ty.to_pretty_string(); + let name = if let Some((_, trait_path, _)) = &i.trait_ { + if path_ends_with(trait_path, "Default") { + // Can't think of how to generate a viable different default. + return; + } + format!( + "", + trait = trait_path.to_pretty_string() + ) + } else { + type_name + }; + v.in_namespace(&name, |vv| syn::visit::visit_item_impl(vv, i)); + }); } /// Visit `trait Foo { ... }` @@ -603,11 +650,9 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { if attrs_excluded(&i.attrs) { return; } - if !self.push_exclude_re(&i.attrs) { - return; - } - self.in_namespace(&name, |v| syn::visit::visit_item_trait(v, i)); - self.pop_exclude_re(); + self.in_exclude_re_scope(&i.attrs, |v| { + v.in_namespace(&name, |vv| syn::visit::visit_item_trait(vv, i)); + }); } /// Visit `mod foo { ... }` or `mod foo;`. @@ -618,43 +663,42 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { trace!("mod excluded by attrs"); return; } - if !self.push_exclude_re(&node.attrs) { - return; - } - - let source_location = Span::from(node.span()); - - // Extract path attribute value, if any (e.g. `#[path="..."]`) - let path_attribute = match find_path_attribute(&node.attrs) { - Ok(path) => path, - Err(path_attribute) => { - let definition_site = self - .source_file - .format_source_location(source_location.start); - error!(?path_attribute, ?definition_site, %mod_name, "invalid filesystem traversal in mod path attribute"); - return; + self.in_exclude_re_scope(&node.attrs, |v| { + let source_location = Span::from(node.span()); + + // Extract path attribute value, if any (e.g. `#[path="..."]`) + let path_attribute = match find_path_attribute(&node.attrs) { + Ok(path) => path, + Err(path_attribute) => { + let definition_site = v + .source_file + .format_source_location(source_location.start); + error!(?path_attribute, ?definition_site, %mod_name, "invalid filesystem traversal in mod path attribute"); + return; + } + }; + let mod_namespace = ModNamespace { + name: mod_name.clone(), + path_attribute, + source_location, + }; + v.mod_namespace_stack.push(mod_namespace.clone()); + + // If there's no content in braces, then this is a `mod foo;` + // statement referring to an external file. We remember the module + // name and then later look for the file. + if node.content.is_none() { + // If we're already inside `mod a { ... }` and see `mod b;` then + // remember [a, b] as an external module to visit later. + v.external_mods.push(ExternalModRef { + parts: v.mod_namespace_stack.clone(), + }); } - }; - let mod_namespace = ModNamespace { - name: mod_name, - path_attribute, - source_location, - }; - self.mod_namespace_stack.push(mod_namespace.clone()); - - // If there's no content in braces, then this is a `mod foo;` - // statement referring to an external file. We remember the module - // name and then later look for the file. - if node.content.is_none() { - // If we're already inside `mod a { ... }` and see `mod b;` then - // remember [a, b] as an external module to visit later. - self.external_mods.push(ExternalModRef { - parts: self.mod_namespace_stack.clone(), + v.in_namespace(&mod_namespace.name, |vv| { + syn::visit::visit_item_mod(vv, node); }); - } - self.in_namespace(&mod_namespace.name, |v| syn::visit::visit_item_mod(v, node)); - assert_eq!(self.mod_namespace_stack.pop(), Some(mod_namespace)); - self.pop_exclude_re(); + assert_eq!(v.mod_namespace_stack.pop(), Some(mod_namespace)); + }); } /// Visit `a op b` expressions. @@ -1035,69 +1079,102 @@ fn attr_is_mutants_skip(attr: &Attribute) -> bool { /// Extract regex patterns from `#[mutants::exclude_re("...")]` attributes. /// -/// This also handles `#[cfg_attr(test, mutants::exclude_re("..."))]`. -fn attrs_exclude_re_patterns(attrs: &[Attribute]) -> Vec { - attrs - .iter() - .filter_map(attr_mutants_exclude_re_pattern) - .collect() +/// This also handles `#[cfg_attr(test, mutants::exclude_re("..."))]`, where +/// the cfg condition is ignored (we always look for the attribute, matching +/// the existing handling of `mutants::skip` within `cfg_attr`). +/// +/// Returns the patterns in order if every relevant attribute parsed cleanly, +/// or a `(span, message)` describing the first malformed occurrence. The +/// span identifies the offending attribute so the caller can report a +/// source location to the user. +fn attrs_exclude_re_patterns( + attrs: &[Attribute], +) -> std::result::Result, (proc_macro2::Span, String)> { + let mut patterns = Vec::new(); + for attr in attrs { + if path_is(attr.path(), &["mutants", "exclude_re"]) { + let pattern = parse_exclude_re_args(attr).map_err(|err| { + ( + attr.span(), + format!("malformed #[mutants::exclude_re(...)] attribute: {err}"), + ) + })?; + patterns.push(pattern); + } else if path_is(attr.path(), &["cfg_attr"]) { + collect_exclude_re_from_cfg_attr(attr, &mut patterns)?; + } + } + Ok(patterns) } -/// If this attribute is `#[mutants::exclude_re("pattern")]`, return the pattern string. +/// Parse the arguments of a direct `#[mutants::exclude_re("pat")]` attribute. /// -/// Also matches `#[cfg_attr(..., mutants::exclude_re("pattern"))]`. -fn attr_mutants_exclude_re_pattern(attr: &Attribute) -> Option { - if path_is(attr.path(), &["mutants", "exclude_re"]) { - return extract_string_from_attr(attr); - } - if !path_is(attr.path(), &["cfg_attr"]) { - return None; - } - // For cfg_attr, we need to find mutants::exclude_re("...") in the token list. - // The tokens look like: `test, mutants::exclude_re("pattern")` - // We use syn to parse the inner attribute. - let tokens = match &attr.meta { - syn::Meta::List(list) => &list.tokens, - _ => return None, - }; - // Wrap the inner content after the condition as an attribute and try to parse it. - // We need to find the portion after the first comma that looks like mutants::exclude_re("...") - let token_str = tokens.to_string(); - // Find "mutants :: exclude_re" (with possible spaces around ::) - // by scanning for the pattern in the token representation. - let normalized = token_str.replace(" :: ", "::"); - let marker = "mutants::exclude_re"; - let start = normalized.find(marker)?; - // Extract from the marker onward in the original token string. - // Find the same position in the original string, accounting for space normalization. - let remaining = &normalized[start + marker.len()..]; - // remaining should start with something like `("pattern")` - let remaining = remaining.trim_start(); - if !remaining.starts_with('(') { - return None; - } - // Find matching close paren, accounting for the string content - let inner = &remaining[1..]; // skip '(' - // Parse the content as a string literal - let close_paren = inner.rfind(')')?; - let literal_str = inner[..close_paren].trim(); - // Parse as a Rust string literal - syn::parse_str::(literal_str) - .ok() - .map(|lit| lit.value()) +/// Requires exactly one string literal argument; everything else is a parse +/// error. +fn parse_exclude_re_args(attr: &Attribute) -> syn::Result { + attr.parse_args_with(|input: syn::parse::ParseStream| -> syn::Result { + let lit: syn::LitStr = input.parse()?; + if !input.is_empty() { + return Err(input + .error("expected exactly one string literal argument to #[mutants::exclude_re]")); + } + Ok(lit.value()) + }) } -/// Extract a string literal argument from an attribute like `#[something("value")]`. -fn extract_string_from_attr(attr: &Attribute) -> Option { - let meta = &attr.meta; - if let syn::Meta::List(list) = meta { - let tokens = &list.tokens; - // Parse the tokens as a single string literal - if let Ok(lit) = syn::parse2::(tokens.clone()) { - return Some(lit.value()); +/// Walk a `#[cfg_attr(..., mutants::exclude_re("pat"), ...)]` attribute, +/// appending every found pattern to `patterns`. +/// +/// If the `cfg_attr` itself cannot be parsed as nested meta (e.g. it has a +/// cfg condition shape we don't understand here), the attribute is +/// silently ignored — matching how [`attr_is_mutants_skip`] handles the +/// same case. But if we successfully locate a `mutants::exclude_re` inside +/// and its arguments are malformed, that is reported as a hard error so +/// users don't think their exclude is in effect when it isn't. +fn collect_exclude_re_from_cfg_attr( + attr: &Attribute, + patterns: &mut Vec, +) -> std::result::Result<(), (proc_macro2::Span, String)> { + let mut malformed: Option<(proc_macro2::Span, String)> = None; + let parse_result = attr.parse_nested_meta(|meta| { + if !path_is(&meta.path, &["mutants", "exclude_re"]) { + return Ok(()); + } + let span = meta.path.span(); + let inner: syn::Result = (|| { + let content; + syn::parenthesized!(content in meta.input); + let lit: syn::LitStr = content.parse()?; + if !content.is_empty() { + return Err(content.error( + "expected exactly one string literal argument to #[mutants::exclude_re]", + )); + } + Ok(lit.value()) + })(); + match inner { + Ok(pattern) => { + patterns.push(pattern); + Ok(()) + } + Err(err) => { + malformed.get_or_insert(( + span, + format!("malformed #[mutants::exclude_re(...)] inside cfg_attr: {err}"), + )); + // Propagate to abort parse_nested_meta; the recorded error + // takes precedence over the parse_result. + Err(err) + } } + }); + if let Some(err) = malformed { + return Err(err); } - None + if let Err(err) = parse_result { + trace!(?attr, ?err, "Could not fully parse cfg_attr nested meta"); + } + Ok(()) } /// Finds the first path attribute (`#[path = "..."]`) @@ -1817,7 +1894,7 @@ mod test { let options = Options::default(); let mutants = mutate_source_str( indoc! {r#" - #[mutants::exclude_re("with \\(\\)")] + #[mutants::exclude_re("with 0")] fn add(a: i32, b: i32) -> i32 { a + b } @@ -1826,12 +1903,20 @@ mod test { ) .unwrap(); let names: Vec<&str> = mutants.iter().map(|m| m.name.as_str()).collect(); - // The fn replacement "replace add -> i32 with 0" etc should remain, - // but "replace add -> i32 with ()" should be excluded. - // Also binary operator mutations remain. + // "replace add -> i32 with 0" should be filtered out, but + // "replace add -> i32 with 1", "with -1", and the binary operator + // mutations on the body should still be present. + assert!( + !names.iter().any(|n| n.contains("with 0")), + "should not contain 'with 0' mutant but got: {names:?}" + ); assert!( - !names.iter().any(|n| n.contains("with ()")), - "should not contain 'with ()' mutant but got: {names:?}" + names.iter().any(|n| n.contains("with 1")), + "should still contain 'with 1' fn-value mutant: {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("with -1")), + "should still contain 'with -1' fn-value mutant: {names:?}" ); assert!( names.iter().any(|n| n.contains("replace + with")), @@ -1841,6 +1926,9 @@ mod test { #[test] fn exclude_re_attr_keeps_all_when_no_match() { + // A valid regex that doesn't match any generated mutant must have no + // effect, matching the behaviour of `--exclude-re` on the CLI / in + // the config file. let options = Options::default(); let mutants_with_attr = mutate_source_str( indoc! {r#" @@ -1861,7 +1949,16 @@ mod test { &options, ) .unwrap(); - assert_eq!(mutants_with_attr.len(), mutants_without_attr.len()); + let names_with: Vec = mutants_with_attr.iter().map(|m| m.name(false)).collect(); + let names_without: Vec = mutants_without_attr + .iter() + .map(|m| m.name(false)) + .collect(); + assert_eq!( + names_with, names_without, + "exclude_re pattern that matches no mutants should produce the \ + exact same mutants as no attribute at all" + ); } #[test] @@ -1884,6 +1981,95 @@ mod test { ); } + #[test] + fn exclude_re_attr_without_string_arg_returns_error() { + // Missing arguments must not silently no-op; they should be reported + // as malformed so users don't think their exclude is in effect. + let options = Options::default(); + let result = mutate_source_str( + indoc! {" + #[mutants::exclude_re] + fn add(a: i32, b: i32) -> i32 { + a + b + } + "}, + &options, + ); + assert!( + result.is_err(), + "missing arg should produce an error, got: {result:?}" + ); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("malformed"), + "error should mention 'malformed': {err_msg}" + ); + } + + #[test] + fn exclude_re_attr_with_multiple_args_returns_error() { + let options = Options::default(); + let result = mutate_source_str( + indoc! {r#" + #[mutants::exclude_re("a", "b")] + fn add(a: i32, b: i32) -> i32 { + a + b + } + "#}, + &options, + ); + assert!( + result.is_err(), + "extra args should produce an error, got: {result:?}" + ); + } + + #[test] + fn exclude_re_attr_with_non_string_arg_returns_error() { + let options = Options::default(); + let result = mutate_source_str( + indoc! {" + #[mutants::exclude_re(42)] + fn add(a: i32, b: i32) -> i32 { + a + b + } + "}, + &options, + ); + assert!( + result.is_err(), + "non-string arg should produce an error, got: {result:?}" + ); + } + + #[test] + fn exclude_re_attr_error_includes_source_location() { + let options = Options::default(); + let result = mutate_source_str( + indoc! {r#" + fn first() -> i32 { 1 } + + #[mutants::exclude_re("(unclosed")] + fn add(a: i32, b: i32) -> i32 { + a + b + } + "#}, + &options, + ); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + // Error should point at the line of the offending attribute. + assert!( + err_msg.contains("src/main.rs:3"), + "error should include source location pointing at attribute on line 3: {err_msg}" + ); + // And include the bad pattern so the user can find it quickly. + assert!( + err_msg.contains("(unclosed"), + "error should include the offending pattern: {err_msg}" + ); + } + #[test] fn exclude_re_attr_on_impl_block_applies_to_methods() { let options = Options::default(); diff --git a/testdata/exclude_re_attr/src/lib.rs b/testdata/exclude_re_attr/src/lib.rs index 9bdfa547..d5c8fc7c 100644 --- a/testdata/exclude_re_attr/src/lib.rs +++ b/testdata/exclude_re_attr/src/lib.rs @@ -3,10 +3,10 @@ //! This tests that specific mutations can be excluded by regex while //! keeping other mutations active on the same function. -/// This function has an exclude_re that filters out the "replace with ()" mutation -/// but keeps binary operator mutations. -/// Filtered: "replace add_numbers -> i32 with ()" -#[mutants::exclude_re(r"with \(\)")] +/// This function has an exclude_re that filters out the "with 0" FnValue +/// mutation but keeps "with 1", "with -1", and the binary operator mutations. +/// Filtered: "replace add_numbers -> i32 with 0" +#[mutants::exclude_re("with 0")] pub fn add_numbers(a: i32, b: i32) -> i32 { a + b } diff --git a/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap b/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap index 8e71b398..7984fae9 100644 --- a/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap +++ b/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap @@ -4,7 +4,6 @@ assertion_line: 33 expression: "String::from_utf8_lossy(&output.stdout)" --- -src/lib.rs:11:5: replace add_numbers -> i32 with 0 src/lib.rs:11:5: replace add_numbers -> i32 with 1 src/lib.rs:11:5: replace add_numbers -> i32 with -1 src/lib.rs:11:7: replace + with - in add_numbers