diff --git a/Cargo.lock b/Cargo.lock index ca88b455..529740e9 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/NEWS.md b/NEWS.md index 26d52a02..581301a3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,6 +4,7 @@ - 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`. - Fixed: Set the mtime on files copied using reflinks to the scratch directory, so that they're not deleted prematurely by tools that delete old files from `/tmp`. ## 27.0.0 diff --git a/book/src/attrs.md b/book/src/attrs.md index a85b00b1..6ab43ce7 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 on an `i32`-returning function except the +"replace ... -> i32 with 0" return-value mutation: + +```rust +#[mutants::exclude_re("with 0")] +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/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/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..1d2a91fa 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; @@ -29,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. @@ -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,97 @@ impl DiscoveryVisitor<'_> { ); } + /// Push `#[mutants::exclude_re("...")]` patterns from the given attributes onto the stack. + /// + /// 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 = 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; + } + match RegexSet::new(&patterns) { + Ok(re) => { + self.exclude_re_stack.push(re); + true + } + Err(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 { + self.exclude_re_stack.iter().any(|re| re.is_match(name)) + } + /// Record that we generated some mutants. fn collect_mutant( &mut self, @@ -332,7 +442,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 +543,9 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { trace!("file excluded by attrs"); return; } - syn::visit::visit_file(self, i); + self.in_exclude_re_scope(&i.attrs, |v| { + syn::visit::visit_file(v, i); + }); } /// Visit top-level `fn foo()`. @@ -444,10 +561,12 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { if fn_sig_excluded(&i.sig) || attrs_excluded(&i.attrs) || block_is_empty(&i.block) { 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.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`. @@ -468,10 +587,12 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { { 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.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. @@ -490,10 +611,12 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { if block_is_empty(block) { 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.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); + }); } } @@ -502,17 +625,22 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { if attrs_excluded(&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. - return; - } - format!("", trait = trait_path.to_pretty_string()) - } else { - type_name - }; - self.in_namespace(&name, |v| syn::visit::visit_item_impl(v, i)); + 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 { ... }` @@ -522,7 +650,9 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { if attrs_excluded(&i.attrs) { return; } - self.in_namespace(&name, |v| syn::visit::visit_item_trait(v, i)); + 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;`. @@ -533,39 +663,42 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { trace!("mod excluded by 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)); + assert_eq!(v.mod_namespace_stack.pop(), Some(mod_namespace)); + }); } /// Visit `a op b` expressions. @@ -744,7 +877,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 +1077,106 @@ 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("..."))]`, 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) +} + +/// Parse the arguments of a direct `#[mutants::exclude_re("pat")]` attribute. +/// +/// 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()) + }) +} + +/// 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); + } + if let Err(err) = parse_result { + trace!(?attr, ?err, "Could not fully parse cfg_attr nested meta"); + } + Ok(()) +} + /// Finds the first path attribute (`#[path = "..."]`) /// /// # Errors @@ -1653,4 +1888,378 @@ 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 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(); + // "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 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")), + "should still contain binary op mutant: {names:?}" + ); + } + + #[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#" + #[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(); + 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] + 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_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(); + 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..d5c8fc7c --- /dev/null +++ b/testdata/exclude_re_attr/src/lib.rs @@ -0,0 +1,155 @@ +//! 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 "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 +} + +/// 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, 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 +} + +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 { + x > 0 + } + + pub fn double(x: i32) -> i32 { + x + x + } +} + +/// 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 { + true + } + + fn score(&self) -> i32 { + 1 + 2 + } +} + +/// 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 { + 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. + /// 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 + } + + 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 376ba105..3fbb3791 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..7984fae9 --- /dev/null +++ b/tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap @@ -0,0 +1,48 @@ +--- +source: tests/util/mod.rs +assertion_line: 33 +expression: "String::from_utf8_lossy(&output.stdout)" + +--- +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 +