Skip to content

Add #[mutants::exclude_re("pattern")] attribute#607

Open
sandersaares wants to merge 7 commits into
sourcefrog:mainfrom
sandersaares:exclude-re-attr
Open

Add #[mutants::exclude_re("pattern")] attribute#607
sandersaares wants to merge 7 commits into
sourcefrog:mainfrom
sandersaares:exclude-re-attr

Conversation

@sandersaares
Copy link
Copy Markdown

@sandersaares sandersaares commented Apr 14, 2026

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.

Changes

  • mutants_attrs/src/lib.rs: New exclude_re proc-macro attribute (no-op, like skip)
  • src/visit.rs: Core implementation - exclude_re_stack on the discovery visitor, push/pop at scope boundaries, attrs_exclude_re_patterns parsing, filtering in collect_mutant
  • testdata/exclude_re_attr/: Test tree exercising all supported scopes (function, impl, trait, mod, scope inheritance, cfg_attr)
  • tests/main.rs: Integration test
  • book/src/attrs.md: Documentation
  • NEWS.md: Changelog entry

Design decisions

  • Invalid regex in an attribute returns a hard error, matching --exclude-re CLI behavior
  • Multiple #[mutants::exclude_re] attributes on the same item are OR'd
  • Patterns on outer scopes (impl/trait/mod/file) are inherited by all items within (not 100% sure how valuable it is but it was trivial to add and feels "logically fitting" so maybe useful)

Closes #551

@sandersaares sandersaares marked this pull request as ready for review April 14, 2026 02:43
@sourcefrog sourcefrog self-assigned this Apr 16, 2026
@sourcefrog sourcefrog requested a review from Copilot April 16, 2026 14:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_re proc-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_mod pushes an exclude_re scope, but the early-return path when find_path_attribute reports 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.

Comment thread src/visit.rs Outdated
Comment on lines +1056 to +1087
// 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())
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in d0314a9. The fragile to_string + substring scan is replaced with Attribute::parse_nested_meta. Each nested meta whose path is mutants::exclude_re has its argument parsed as a syn::LitStr via syn::parenthesized! and content.parse::<LitStr>(). Multiple mutants::exclude_re entries in one cfg_attr and arbitrary whitespace are both handled correctly.

Comment thread src/visit.rs Outdated
Comment on lines +1090 to +1098
/// 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());
}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in d0314a9. attrs_exclude_re_patterns now returns Result<Vec<String>, (Span, String)> and every malformed shape is a hard error stored on visitor.error (matching invalid-regex behaviour):

  • #[mutants::exclude_re] (no argument)
  • #[mutants::exclude_re("a", "b")] (multiple arguments)
  • #[mutants::exclude_re(123)] (non-string argument)

New unit tests:

  • exclude_re_attr_without_string_arg_returns_error
  • exclude_re_attr_with_multiple_args_returns_error
  • exclude_re_attr_with_non_string_arg_returns_error
  • exclude_re_attr_error_includes_source_location

Comment thread src/visit.rs
Comment on lines +1816 to +1835
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:?}"
);
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — addressed in d0314a9. The test (now exclude_re_attr_filters_specific_mutants) excludes "with 0" against an -> i32 function and asserts that:

  • no with 0 mutant remains (would otherwise be one of three default i32 mutants), and
  • the other expected mutants (with 1, with -1, plus the binary-op mutants) all do still appear.

If the regex were silently dropped, the surviving-mutants assertion would still pass but the "no with 0" assertion would fail. Snapshot main__util__list_mutants_in_exclude_re_attr.snap was also regenerated and now visibly drops the with 0 line for add_numbers.

Comment thread book/src/attrs.md Outdated
Comment on lines +63 to +67
For example, to keep all mutations except the "replace with ()" return-value
mutation:

```rust
#[mutants::exclude_re(r"with \(\)")]
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I thought so too.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d0314a9. The book example now uses #[mutants::exclude_re("with 0")] against an i32 function, which is a pattern that actually matches one of cargo-mutants' default i32 mutants.

Comment thread testdata/exclude_re_attr/src/lib.rs Outdated
Comment on lines +6 to +9
/// 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 \(\)")]
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d0314a9. The fixture's add_numbers now uses #[mutants::exclude_re("with 0")], which actually matches the with 0 mutant cargo-mutants generates for -> i32 functions. The regenerated snapshot main__util__list_mutants_in_exclude_re_attr.snap no longer contains replace add_numbers -> i32 with 0, demonstrating that the attribute is working.

Comment thread mutants_attrs/src/lib.rs
pub fn skip(_attr: TokenStream, item: TokenStream) -> TokenStream {
item
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We ought to also bump the version of mutants_attrs/Cargo.toml.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in d0314a9 — bumped to 0.0.5. Happy to make it 0.1.0 instead if you'd prefer a more notable bump for the new public attribute.

Comment thread src/visit.rs Outdated
}
Err(err) => {
self.error.get_or_insert(anyhow!(
"invalid regex in #[mutants::exclude_re]: {err}"
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in d0314a9. Errors now include the source file/line of the offending attribute and the offending pattern(s). For example:

src/main.rs:3:1: invalid regex in #[mutants::exclude_re("(unclosed")]: regex parse error: ...

Covered by the new exclude_re_attr_error_includes_source_location test.

Comment thread src/visit.rs Outdated
};
// 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();
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in d0314a9. Replaced the TokenStream::to_string() substring scan with a proper AST walk:

  • Direct #[mutants::exclude_re(...)] is now parsed with Attribute::parse_args_with + syn::LitStr.
  • #[cfg_attr(cond, mutants::exclude_re(...), ...)] uses Attribute::parse_nested_meta, then syn::parenthesized! + LitStr for each nested mutants::exclude_re arg, mirroring the style of attr_is_mutants_skip.
  • Multiple mutants::exclude_re entries within a single cfg_attr are all collected.
  • Arbitrary whitespace inside the attribute no longer matters.

Comment thread book/src/attrs.md Outdated
Comment on lines +63 to +67
For example, to keep all mutations except the "replace with ()" return-value
mutation:

```rust
#[mutants::exclude_re(r"with \(\)")]
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I thought so too.

sandersaares and others added 6 commits May 11, 2026 14:35
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.
- mutants_attrs: bump version 0.0.4 -> 0.0.5 for the new exclude_re
  attribute.
- visit.rs: replace TokenStream::to_string() + substring scan in
  attr_mutants_exclude_re_pattern with AST-based parsing using
  Attribute::parse_args_with and Attribute::parse_nested_meta, mirroring
  attr_is_mutants_skip. Handles multiple exclude_re attrs in one
  cfg_attr, arbitrary whitespace, and other token shapes correctly.
- visit.rs: malformed #[mutants::exclude_re] (missing arg, multiple
  args, non-string arg) is now a hard error stored on visitor.error,
  matching the existing invalid-regex behaviour. Previously these were
  silently no-ops, giving users a false sense of safety.
- visit.rs: invalid-regex error now includes the source file/line of
  the offending attribute and the offending pattern, e.g.
  "src/main.rs:3:1: invalid regex in #[mutants::exclude_re(\"(unclosed\")]: ...".
- visit.rs: introduce in_exclude_re_scope closure helper that
  guarantees pop_exclude_re runs even if the visited body returns
  early. visit_item_mod previously left the exclude_re stack
  unbalanced on its invalid-#[path] early return; this also covers
  future early-return paths in visit_item_impl and friends.
- Tests: rewrite exclude_re_attr_filters_specific_mutants to actually
  exercise filtering on an i32 function (exclude "with 0", assert
  "with 1"/"with -1"/binop survive) - previously the regex with \(\)
  was a no-op on i32, so the test passed even if filtering was broken.
- Tests: strengthen exclude_re_attr_keeps_all_when_no_match to compare
  position-independent mutant names, not just lengths.
- Tests: add regression tests for malformed attribute forms and for
  the source-location in the error message.
- testdata/exclude_re_attr & book/src/attrs.md: switch the misleading
  with \(\) example to with 0 so it actually demonstrates the
  attribute working on an i32 function. Regenerate the integration
  snapshot, which now visibly shows with 0 filtered out of
  add_numbers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sandersaares
Copy link
Copy Markdown
Author

sandersaares commented May 11, 2026

Addressed all feedback. Robot summary:

Owner feedback (@sourcefrog):

  • mutants_attrs version bumped 0.0.4 → 0.0.5.
  • Invalid-regex (and malformed-attribute) errors now include the source file/line and the bad pattern.
  • cfg_attr parsing rewritten from TokenStream::to_string() substring scan to proper AST traversal via Attribute::parse_nested_meta + syn::LitStr.
  • Misleading i32 + with () example in book/src/attrs.md replaced with "with 0".

Copilot reviewer feedback:

  • Fragile string scan replaced with AST parse (above).
  • Malformed #[mutants::exclude_re] (no arg / multiple args / non-string arg) is now a hard error instead of silent no-op, with 4 new unit tests.
  • Unit test that previously couldn't have detected a broken regex filter has been rewritten to exclude with 0 against -> i32 and now asserts that with 0 is gone while with 1, with -1, and the binop mutants survive.
  • Same misleading example fixed in book/src/attrs.md and testdata/exclude_re_attr/src/lib.rs; the snapshot main__util__list_mutants_in_exclude_re_attr.snap was regenerated and now visibly omits replace add_numbers -> i32 with 0.

Also addressed (from the suppressed low-confidence review comment about visit_item_mod): the early-return path on an invalid #[path] attribute could leave exclude_re_stack unbalanced. Fixed by introducing an in_exclude_re_scope(attrs, |v| { ... }) closure helper that does the push/pop around the visit body, and converting every visitor method (visit_file, visit_item_fn, visit_impl_item_fn, visit_trait_item_fn, visit_item_impl, visit_item_trait, visit_item_mod) to use it, so a future early return can't desync the stack.

Also added exclude_re_attr_keeps_all_when_no_match, strengthened to compare full mutant names — proves that a pattern matching nothing produces the exact same mutant set as no attribute at all, matching --exclude-re CLI behaviour.

@sourcefrog sourcefrog self-requested a review May 11, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: define regex excludes via attributes in code

3 participants