Add #[mutants::exclude_re("pattern")] attribute#607
Add #[mutants::exclude_re("pattern")] attribute#607sandersaares wants to merge 5 commits intosourcefrog:mainfrom
Conversation
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 sourcefrog#551
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.
There was a problem hiding this comment.
Pull request overview
Adds a new #[mutants::exclude_re("...")] attribute to allow excluding only specific mutants (by regex) while still generating other mutations, including inheritance from outer scopes and support inside cfg_attr.
Changes:
- Introduces the
mutants::exclude_reproc-macro attribute (no-op at compile time) and documents it. - Implements exclude-by-regex behavior in the discovery visitor via an inherited scope stack, plus regex parsing from attributes (including
cfg_attr). - Adds integration + fixture coverage (new testdata tree, new snapshot, and a new integration test).
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
mutants_attrs/src/lib.rs |
Adds the exclude_re proc-macro attribute entry point and docs. |
src/visit.rs |
Implements exclude-re parsing, scope inheritance, and filtering during mutant collection; adds unit tests. |
testdata/exclude_re_attr/src/lib.rs |
New fixture crate source exercising supported scopes. |
testdata/exclude_re_attr/Cargo_test.toml |
New fixture crate manifest for the exclude-re attribute tests. |
tests/main.rs |
Adds an integration test to snapshot --list output for the new fixture. |
tests/util/snapshots/main__util__list_mutants_in_exclude_re_attr.snap |
Snapshot for the new integration test output. |
book/src/attrs.md |
Documents the new attribute, usage, and scope inheritance rules. |
NEWS.md |
Adds an “Unreleased” changelog entry for the new attribute. |
Comments suppressed due to low confidence (1)
src/visit.rs:636
visit_item_modpushes an exclude_re scope, but the early-return path whenfind_path_attributereports an invalid (absolute)#[path]returns without popping. That leaves the exclude_re stack unbalanced and can incorrectly apply the module’s exclude patterns to the rest of the file. Pop the scope before returning (or use an RAII guard to ensure pop on all exits).
if !self.push_exclude_re(&node.attrs) {
return;
}
let source_location = Span::from(node.span());
// Extract path attribute value, if any (e.g. `#[path="..."]`)
let path_attribute = match find_path_attribute(&node.attrs) {
Ok(path) => path,
Err(path_attribute) => {
let definition_site = self
.source_file
.format_source_location(source_location.start);
error!(?path_attribute, ?definition_site, %mod_name, "invalid filesystem traversal in mod path attribute");
return;
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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::<syn::LitStr>(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<String> { | ||
| 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::<syn::LitStr>(tokens.clone()) { | ||
| return Some(lit.value()); | ||
| } |
| 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:?}" | ||
| ); |
| For example, to keep all mutations except the "replace with ()" return-value | ||
| mutation: | ||
|
|
||
| ```rust | ||
| #[mutants::exclude_re(r"with \(\)")] |
| /// 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 skip(_attr: TokenStream, item: TokenStream) -> TokenStream { | ||
| item | ||
| } | ||
|
|
There was a problem hiding this comment.
We ought to also bump the version of mutants_attrs/Cargo.toml.
| } | ||
| Err(err) => { | ||
| self.error.get_or_insert(anyhow!( | ||
| "invalid regex in #[mutants::exclude_re]: {err}" |
There was a problem hiding this comment.
I guess it might be nice to show the file/line here or at least the bad regexp? However this is already fairly large, and perhaps that can wait until later.
| }; | ||
| // 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(); |
There was a problem hiding this comment.
I think we could possibly parse this using the AST, rather than converting to a string and then doing ad-hoc parsing? However it's not terrible as it is. Does syn not parse inside the attribute?
| For example, to keep all mutations except the "replace with ()" return-value | ||
| mutation: | ||
|
|
||
| ```rust | ||
| #[mutants::exclude_re(r"with \(\)")] |
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,
implblocks,traitblocks, modules, and files (as an inner attribute). Patterns from outer scopes are inherited. Also supported withincfg_attr.Changes
exclude_reproc-macro attribute (no-op, likeskip)exclude_re_stackon the discovery visitor, push/pop at scope boundaries,attrs_exclude_re_patternsparsing, filtering incollect_mutantDesign decisions
--exclude-reCLI behavior#[mutants::exclude_re]attributes on the same item are OR'dCloses #551