From 137ec77f8d18cb541154abd81bd1aa276b058485 Mon Sep 17 00:00:00 2001 From: Sander Saares Date: Tue, 14 Apr 2026 10:29:25 +0800 Subject: [PATCH 1/5] 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 | 4 + 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, 713 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 863e3ce8..fb4f3ad7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,9 @@ # cargo-mutants changelog +## Unreleased + +- 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 a4ef2a7a..81148cbe 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -3179,6 +3179,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 7cc7410d42b0268412bb691b7bded2597c0ed594 Mon Sep 17 00:00:00 2001 From: Sander Saares Date: Tue, 14 Apr 2026 10:32:14 +0800 Subject: [PATCH 2/5] 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 b059fd6865a5d8a0a24f4b953e24c0d2a07a88ba Mon Sep 17 00:00:00 2001 From: Sander Saares Date: Tue, 14 Apr 2026 10:34:11 +0800 Subject: [PATCH 3/5] 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 fd5045e54d82cb98a60f57e9c00376497a5c76b0 Mon Sep 17 00:00:00 2001 From: Sander Saares Date: Tue, 14 Apr 2026 10:40:36 +0800 Subject: [PATCH 4/5] 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 63b9c32f54206c9193250719c0492c876bda8ff2 Mon Sep 17 00:00:00 2001 From: Sander Saares Date: Tue, 14 Apr 2026 10:42:48 +0800 Subject: [PATCH 5/5] 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