Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions book/src/attrs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
93 changes: 87 additions & 6 deletions src/visit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,27 @@ fn walk_package(
) -> Result<(Vec<Mutant>, Vec<SourceFile>)> {
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:?}");
continue;
};
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
Expand All @@ -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) {
Expand All @@ -137,15 +151,31 @@ pub fn walk_file(
source_file: &SourceFile,
error_exprs: &[Expr],
options: &Options,
) -> Result<(Vec<Mutant>, Vec<ExternalModRef>)> {
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<Mutant>, Vec<ExternalModRef>)> {
let _span = debug_span!("source_file", path = source_file.tree_relative_slashes()).entered();
trace!("visit source file");
let syn_file = syn::parse_str::<syn::File>(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(),
Expand Down Expand Up @@ -218,6 +248,9 @@ pub fn mutate_expr(code: &str) -> Vec<String> {
pub struct ExternalModRef {
/// Namespace components of the module path
parts: Vec<ModNamespace>,

/// Attribute regexes inherited by the external module file.
exclude_re_patterns: Vec<String>,
}

/// Namespace for a module defined in a `mod foo { ... }` block or `mod foo;` statement
Expand Down Expand Up @@ -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<String> {
self.exclude_re_stack
.iter()
.flat_map(|re| re.patterns().iter().cloned())
.collect()
}

/// Record that we generated some mutants.
fn collect_mutant(
&mut self,
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions testdata/exclude_re_external_mod/Cargo_test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[package]
name = "cargo-mutants-testdata-exclude-re-external-mod"
publish = false
version = "0.0.0"
edition = "2024"

[dependencies]
5 changes: 5 additions & 0 deletions testdata/exclude_re_external_mod/README.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions testdata/exclude_re_external_mod/src/external.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
mod nested;

pub fn f() -> bool {
true
}

pub fn g() -> bool {
true
}
7 changes: 7 additions & 0 deletions testdata/exclude_re_external_mod/src/external/nested.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
pub fn nested_f() -> bool {
true
}

pub fn nested_g() -> bool {
true
}
9 changes: 9 additions & 0 deletions testdata/exclude_re_external_mod/src/inner_attr.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#![mutants::exclude_re("replace inner_f")]

pub fn inner_f() -> bool {
true
}

pub fn inner_g() -> bool {
true
}
16 changes: 16 additions & 0 deletions testdata/exclude_re_external_mod/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;