diff --git a/NEWS.md b/NEWS.md index 646847c4..ebd1bf35 100644 --- a/NEWS.md +++ b/NEWS.md @@ -3,6 +3,7 @@ ## 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, files, and on expressions that can carry an attribute (such as `match`, struct literals, call expressions, method calls, and unary expressions). Multiple patterns can be applied. Also supported within `cfg_attr`. Requires the [mutants](https://crates.io/crates/mutants) crate version `0.0.5` or later. +- Fixed: `#[mutants::exclude_re("pattern")]` attributes on external module declarations and enclosing scopes are now inherited by the referenced module files, including further nested external modules. - Fixed: `#[mutants::skip]` (and `#[cfg_attr(..., mutants::skip)]`) is now honoured when placed on `const` and `static` items, including associated constants in `impl` and `trait` blocks. Previously the attribute was silently ignored on these items and operator mutants inside the initializer expression were still generated ([#508](https://github.com/sourcefrog/cargo-mutants/issues/508)). ## 27.1.0 diff --git a/book/src/attrs.md b/book/src/attrs.md index 830be401..e60adbae 100644 --- a/book/src/attrs.md +++ b/book/src/attrs.md @@ -120,7 +120,8 @@ fn is_valid(&self) -> bool { - **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. +- **Modules** — applies to all items within the module, including when the + module is declared in a separate file. - **Files** (as an inner attribute `#![mutants::exclude_re("...")]`) — applies to the entire file. - **Expressions** that can syntactically carry an outer attribute, including `match`, struct literal (`Foo { ... }`), call (`foo(...)`), method-call @@ -129,4 +130,5 @@ fn is_valid(&self) -> bool { 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. +methods themselves. This inheritance continues across external module file +boundaries. diff --git a/src/visit.rs b/src/visit.rs index d3d9fa8a..c02b6e06 100644 --- a/src/visit.rs +++ b/src/visit.rs @@ -93,9 +93,14 @@ fn walk_package( ) -> Result<(Vec, Vec)> { let mut mutants = Vec::new(); let mut files = Vec::new(); - let mut filename_queue = - VecDeque::from_iter(package.top_sources.iter().map(|p| (p.to_owned(), true))); - while let Some((path, package_top)) = filename_queue.pop_front() { + let mut filename_queue = VecDeque::from_iter( + package + .top_sources + .iter() + .map(|p| (p.to_owned(), true, Vec::new())), + ); + while let Some((path, package_top, inherited_exclude_re_patterns)) = filename_queue.pop_front() + { let Some(source_file) = SourceFile::load(workspace_dir, &path, package, package_top)? else { info!("Skipping source file outside of tree: {path:?}"); @@ -103,7 +108,12 @@ fn walk_package( }; progress.increment_files(1); check_interrupted()?; - let (mut file_mutants, external_mods) = walk_file(&source_file, error_exprs, options)?; + let (mut file_mutants, external_mods) = walk_file_with_exclude_re( + &source_file, + error_exprs, + options, + &inherited_exclude_re_patterns, + )?; progress.increment_mutants(file_mutants.len()); // TODO: It would be better not to spend time generating mutants from // files that are not going to be visited later. However, we probably do @@ -117,7 +127,11 @@ fn walk_package( // `--list-files`. for mod_namespace in &external_mods { if let Some(mod_path) = find_mod_source(workspace_dir, &source_file, mod_namespace) { - filename_queue.push_back((mod_path, false)); + filename_queue.push_back(( + mod_path, + false, + mod_namespace.exclude_re_patterns.clone(), + )); } } if !options.allows_source_file_path(&source_file.tree_relative_path) { @@ -137,15 +151,31 @@ pub fn walk_file( source_file: &SourceFile, error_exprs: &[Expr], options: &Options, +) -> Result<(Vec, Vec)> { + walk_file_with_exclude_re(source_file, error_exprs, options, &[]) +} + +/// Find all possible mutants in a source file, inheriting attribute regexes +/// from the `mod` statement that referenced it. +fn walk_file_with_exclude_re( + source_file: &SourceFile, + error_exprs: &[Expr], + options: &Options, + inherited_exclude_re_patterns: &[String], ) -> Result<(Vec, Vec)> { let _span = debug_span!("source_file", path = source_file.tree_relative_slashes()).entered(); trace!("visit source file"); let syn_file = syn::parse_str::(source_file.code()) .with_context(|| format!("failed to parse {}", source_file.tree_relative_slashes()))?; + let exclude_re_stack = if inherited_exclude_re_patterns.is_empty() { + Vec::new() + } else { + vec![RegexSet::new(inherited_exclude_re_patterns)?] + }; let mut visitor = DiscoveryVisitor { error_exprs, error: None, - exclude_re_stack: Vec::new(), + exclude_re_stack, external_mods: Vec::new(), mutants: Vec::new(), mod_namespace_stack: Vec::new(), @@ -218,6 +248,9 @@ pub fn mutate_expr(code: &str) -> Vec { pub struct ExternalModRef { /// Namespace components of the module path parts: Vec, + + /// Attribute regexes inherited by the external module file. + exclude_re_patterns: Vec, } /// Namespace for a module defined in a `mod foo { ... }` block or `mod foo;` statement @@ -425,6 +458,14 @@ impl DiscoveryVisitor<'_> { self.exclude_re_stack.iter().any(|re| re.is_match(name)) } + /// Return all attribute regex patterns active at the current location. + fn current_exclude_re_patterns(&self) -> Vec { + self.exclude_re_stack + .iter() + .flat_map(|re| re.patterns().iter().cloned()) + .collect() + } + /// Record that we generated some mutants. fn collect_mutant( &mut self, @@ -767,6 +808,7 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { // remember [a, b] as an external module to visit later. v.external_mods.push(ExternalModRef { parts: v.mod_namespace_stack.clone(), + exclude_re_patterns: v.current_exclude_re_patterns(), }); } v.in_namespace(&mod_namespace.name, |vv| { @@ -1378,6 +1420,45 @@ mod test { assert_eq!(discovered.mutants.as_slice(), &[]); } + #[test] + fn exclude_re_attr_on_external_mod_applies_to_child_files() { + let options = Options::default(); + let console = Console::new(); + let tmp = copy_of_testdata("exclude_re_external_mod"); + let workspace = Workspace::open(tmp.path()).unwrap(); + let discovered = workspace + .discover(&PackageFilter::All, &options, &console) + .unwrap(); + let names = discovered + .mutants + .iter() + .map(|mutant| mutant.name(false)) + .collect_vec(); + + for excluded in [ + "replace f ->", + "replace nested_f ->", + "replace inline::inline_f ->", + "replace inner_f ->", + ] { + assert!( + !names.iter().any(|name| name.contains(excluded)), + "{excluded} should be excluded: {names:?}" + ); + } + for retained in [ + "replace g ->", + "replace nested_g ->", + "replace inline::inline_g ->", + "replace inner_g ->", + ] { + assert!( + names.iter().any(|name| name.contains(retained)), + "{retained} should be retained: {names:?}" + ); + } + } + /// Helper function for `find_path_attribute` tests fn run_find_path_attribute( token_stream: &TokenStream, diff --git a/testdata/exclude_re_external_mod/Cargo_test.toml b/testdata/exclude_re_external_mod/Cargo_test.toml new file mode 100644 index 00000000..d29039c1 --- /dev/null +++ b/testdata/exclude_re_external_mod/Cargo_test.toml @@ -0,0 +1,7 @@ +[package] +name = "cargo-mutants-testdata-exclude-re-external-mod" +publish = false +version = "0.0.0" +edition = "2024" + +[dependencies] diff --git a/testdata/exclude_re_external_mod/README.md b/testdata/exclude_re_external_mod/README.md new file mode 100644 index 00000000..39c21010 --- /dev/null +++ b/testdata/exclude_re_external_mod/README.md @@ -0,0 +1,5 @@ +# `exclude_re` on external modules + +Checks that `#[mutants::exclude_re]` scopes are inherited by external module +files and further external modules, while inline modules and file inner +attributes continue to behave the same way. diff --git a/testdata/exclude_re_external_mod/src/external.rs b/testdata/exclude_re_external_mod/src/external.rs new file mode 100644 index 00000000..8a3fd99c --- /dev/null +++ b/testdata/exclude_re_external_mod/src/external.rs @@ -0,0 +1,9 @@ +mod nested; + +pub fn f() -> bool { + true +} + +pub fn g() -> bool { + true +} diff --git a/testdata/exclude_re_external_mod/src/external/nested.rs b/testdata/exclude_re_external_mod/src/external/nested.rs new file mode 100644 index 00000000..c2c5f3bc --- /dev/null +++ b/testdata/exclude_re_external_mod/src/external/nested.rs @@ -0,0 +1,7 @@ +pub fn nested_f() -> bool { + true +} + +pub fn nested_g() -> bool { + true +} diff --git a/testdata/exclude_re_external_mod/src/inner_attr.rs b/testdata/exclude_re_external_mod/src/inner_attr.rs new file mode 100644 index 00000000..ef7603d9 --- /dev/null +++ b/testdata/exclude_re_external_mod/src/inner_attr.rs @@ -0,0 +1,9 @@ +#![mutants::exclude_re("replace inner_f")] + +pub fn inner_f() -> bool { + true +} + +pub fn inner_g() -> bool { + true +} diff --git a/testdata/exclude_re_external_mod/src/lib.rs b/testdata/exclude_re_external_mod/src/lib.rs new file mode 100644 index 00000000..52c49860 --- /dev/null +++ b/testdata/exclude_re_external_mod/src/lib.rs @@ -0,0 +1,16 @@ +#[mutants::exclude_re("replace f")] +#[mutants::exclude_re("nested_f")] +mod external; + +#[mutants::exclude_re("inline_f")] +mod inline { + pub fn inline_f() -> bool { + true + } + + pub fn inline_g() -> bool { + true + } +} + +mod inner_attr;