diff --git a/NEWS.md b/NEWS.md index a72a2d3f..e1f8febe 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,8 @@ ## Unreleased +- New: `#[mutants::skip]` is now honoured on block expressions (`{ ... }`), in both statement and expression position. All mutants generated inside the annotated block are suppressed. Note that the `#[mutants::skip]` attribute on expressions requires a nightly Rust toolchain (`stmt_expr_attributes` and `proc_macro_hygiene` feature gates). + - 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. ## 27.1.0 diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index f281855b..6d18a58d 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -9,7 +9,7 @@ - [Exit codes](exit-codes.md) - [The `mutants.out` directory](mutants-out.md) - [Skipping untestable code](skip.md) - - [Skipping functions with an attribute](attrs.md) + - [Skipping mutations with an attribute](attrs.md) - [Skipping function calls](skip_calls.md) - [Filtering files](skip_files.md) - [Filtering functions and mutants](filter_mutants.md) diff --git a/book/src/attrs.md b/book/src/attrs.md index 2de9bdb1..5607a214 100644 --- a/book/src/attrs.md +++ b/book/src/attrs.md @@ -14,7 +14,7 @@ code. It only flags the item for cargo-mutants. **Note:** `cargo-mutants` does not evaluate the `cfg_attr` condition; the inner `mutants::skip` is always honoured regardless of whether the condition -would hold during compilation. +would hold during compilation. This may change in future versions. You may want to also add a comment explaining why the item is skipped. @@ -60,9 +60,13 @@ mod test { - **`mod` blocks** — applies to all items within the module. - **Files** (as an inner attribute `#![mutants::skip]`) — applies to the entire file. - **Expressions** that can syntactically carry an outer attribute, including - `match`, struct literal (`Foo { ... }`), call (`foo(...)`), method-call - (`x.foo(...)`), and unary expressions (`!x`, `-x`) — applies to the - expression and everything nested inside it. + block (`{ ... }`), `match`, struct literal (`Foo { ... }`), call + (`foo(...)`), method-call (`x.foo(...)`), and unary expressions (`!x`, + `-x`) — applies to the expression and everything nested inside it. + Note that the `#[mutants::skip]` macro on expressions requires the + unstable `stmt_expr_attributes` and `proc_macro_hygiene` features, so + expression-level `#[mutants::skip]` is currently only usable on a + nightly Rust toolchain. ## Excluding specific mutations with an attribute @@ -120,9 +124,10 @@ fn is_valid(&self) -> bool { - **`mod` blocks** — applies to all items within the module. - **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 - (`x.foo(...)`), and unary expressions (`!x`, `-x`) — applies to the mutations - generated for that expression and any expressions nested inside it. + block (`{ ... }`), `match`, struct literal (`Foo { ... }`), call + (`foo(...)`), method-call (`x.foo(...)`), and unary expressions (`!x`, + `-x`) — applies to the mutations generated for that expression and any + expressions nested inside it. 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 diff --git a/src/visit.rs b/src/visit.rs index a4543811..f2f5ce27 100644 --- a/src/visit.rs +++ b/src/visit.rs @@ -903,6 +903,25 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { syn::visit::visit_expr_struct(v, i); }); } + + /// Visit a block expression, e.g. `{ ... }` used as a statement or as the + /// right-hand side of `let x = { ... };`. + /// + /// An outer `#[mutants::skip]` attribute attached to the block (in either + /// position) suppresses mutants generated for every expression inside the + /// block. `Block` itself carries no attributes — they live on the enclosing + /// `ExprBlock`, which is what this handler inspects. + fn visit_expr_block(&mut self, i: &'ast syn::ExprBlock) { + let _span = trace_span!("expr_block", line = i.span().start().line).entered(); + trace!("visit block expression"); + if attrs_excluded(&i.attrs) { + trace!("block excluded by attrs"); + return; + } + self.in_exclude_re_scope(&i.attrs, |v| { + syn::visit::visit_expr_block(v, i); + }); + } } // Get the span of the block excluding the braces, or None if it is empty. @@ -1069,7 +1088,13 @@ fn path_ends_with(path: &syn::Path, ident: &str) -> bool { /// True if the attribute contains `mutants::skip`. /// -/// This for example returns true for `#[mutants::skip]` or `#[cfg_attr(test, mutants::skip)]`. +/// This for example returns true for `#[mutants::skip]` or for +/// `#[cfg_attr(, mutants::skip)]` regardless of what `` is — +/// cargo-mutants does not evaluate the cfg condition, so any predicate +/// shape (a plain ident like `test`, a function-style predicate like +/// `any()`/`not(...)`, or a `name = "value"` form) is treated the same. +/// This is an implementation detail, not a public guarantee; see +/// `book/src/attrs.md` for what we actually promise to users. fn attr_is_mutants_skip(attr: &Attribute) -> bool { if path_is(attr.path(), &["mutants", "skip"]) { return true; @@ -1081,6 +1106,16 @@ fn attr_is_mutants_skip(attr: &Attribute) -> bool { if let Err(err) = attr.parse_nested_meta(|meta| { if path_is(&meta.path, &["mutants", "skip"]) { skip = true; + } else if meta.input.peek(syn::token::Paren) { + // Function-style cfg predicate like `any(...)`, `all(...)`, `not(...)`. + // We don't evaluate the predicate; just consume and discard its + // contents so parse_nested_meta can advance to the next item. + let content; + let _ = syn::parenthesized!(content in meta.input); + let _: proc_macro2::TokenStream = content.parse()?; + } else if meta.input.peek(syn::Token![=]) { + // `name = "value"` form (e.g. `target_os = "linux"`); consume the value. + let _: syn::Expr = meta.value()?.parse()?; } Ok(()) }) { @@ -1155,6 +1190,18 @@ fn collect_exclude_re_from_cfg_attr( 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"]) { + // Consume any cfg predicate arguments so parse_nested_meta can + // advance to the next item — mirroring `attr_is_mutants_skip`, so + // an `exclude_re` after a non-trivial predicate isn't silently lost. + if meta.input.peek(syn::token::Paren) { + // Function-style cfg predicate like `any(...)`, `all(...)`, `not(...)`. + let content; + let _ = syn::parenthesized!(content in meta.input); + let _: proc_macro2::TokenStream = content.parse()?; + } else if meta.input.peek(syn::Token![=]) { + // `name = "value"` form (e.g. `target_os = "linux"`). + let _: syn::Expr = meta.value()?.parse()?; + } return Ok(()); } let span = meta.path.span(); @@ -1242,6 +1289,7 @@ mod test { mod exclude_re_expr_struct; mod exclude_re_expr_unary; mod skip_attr_cfg_attr; + mod skip_attr_expr_block; mod skip_attr_expr_call; mod skip_attr_expr_match; mod skip_attr_expr_method_call; @@ -2156,6 +2204,54 @@ mod test { ); } + #[test] + fn cfg_attr_exclude_re_with_function_style_predicate_still_filters_mutants() { + let options = Options::default(); + let mutants = mutate_source_str( + indoc! {r#" + #[cfg_attr(any(), 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")), + "cfg_attr exclude_re with a function-style predicate must still filter: {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("replace + with")), + "should still contain binary op mutant: {names:?}" + ); + } + + #[test] + fn cfg_attr_exclude_re_with_name_value_predicate_still_filters_mutants() { + let options = Options::default(); + let mutants = mutate_source_str( + indoc! {r#" + #[cfg_attr(target_os = "linux", 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")), + "cfg_attr exclude_re with a name = value predicate must still filter: {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(); diff --git a/src/visit/test/skip_attr_cfg_attr.rs b/src/visit/test/skip_attr_cfg_attr.rs index 439deda6..bc930282 100644 --- a/src/visit/test/skip_attr_cfg_attr.rs +++ b/src/visit/test/skip_attr_cfg_attr.rs @@ -71,3 +71,79 @@ fn cfg_attr_mutants_skip_on_mod_suppresses_inner_items() { "sibling function outside the mod should still produce mutants: {names:?}" ); } + +// The tests below pin the implementation detail that `attr_is_mutants_skip` +// ignores the cfg condition for *every* shape of `cfg_attr` predicate, not +// just plain identifiers like `test`. This keeps the behavior consistent +// regardless of how the user spells the condition. It is not a public +// guarantee — `book/src/attrs.md` deliberately does not promise that the +// condition is ignored — but the consistency matters internally because a +// silently-dropped `mutants::skip` would be very surprising. + +#[test] +fn cfg_attr_with_function_style_predicate_still_treats_mutants_skip_as_skip() { + let mutants = mutate_source_str( + indoc! {r#" + #[cfg_attr(any(), mutants::skip)] + fn add(a: i32, b: i32) -> i32 { + a + b + } + + fn outside(a: i32, b: i32) -> i32 { + a * b + } + "#}, + &Options::default(), + ) + .unwrap(); + let names: Vec = mutants.iter().map(|m| m.name(false)).collect(); + + assert!( + !names.iter().any(|n| n.contains("add")), + "cfg_attr with a function-style predicate must still be recognised as carrying mutants::skip: {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("outside")), + "sibling function should still produce mutants: {names:?}" + ); +} + +#[test] +fn cfg_attr_with_nested_function_style_predicate_still_treats_mutants_skip_as_skip() { + let mutants = mutate_source_str( + indoc! {r#" + #[cfg_attr(not(all()), mutants::skip)] + fn add(a: i32, b: i32) -> i32 { + a + b + } + "#}, + &Options::default(), + ) + .unwrap(); + let names: Vec = mutants.iter().map(|m| m.name(false)).collect(); + + assert!( + !names.iter().any(|n| n.contains("add")), + "cfg_attr with a nested function-style predicate must still be recognised as carrying mutants::skip: {names:?}" + ); +} + +#[test] +fn cfg_attr_with_name_value_predicate_still_treats_mutants_skip_as_skip() { + let mutants = mutate_source_str( + indoc! {r#" + #[cfg_attr(target_os = "linux", mutants::skip)] + fn add(a: i32, b: i32) -> i32 { + a + b + } + "#}, + &Options::default(), + ) + .unwrap(); + let names: Vec = mutants.iter().map(|m| m.name(false)).collect(); + + assert!( + !names.iter().any(|n| n.contains("add")), + "cfg_attr with a name = value predicate must still be recognised as carrying mutants::skip: {names:?}" + ); +} diff --git a/src/visit/test/skip_attr_expr_block.rs b/src/visit/test/skip_attr_expr_block.rs new file mode 100644 index 00000000..de96177a --- /dev/null +++ b/src/visit/test/skip_attr_expr_block.rs @@ -0,0 +1,141 @@ +//! Tests that `#[mutants::skip]` on a block expression `{ ... }` suppresses +//! mutants generated inside that block, while sibling code in the same +//! function remains mutated. + +use indoc::indoc; +use test_log::test; + +use crate::Options; +use crate::visit::mutate_source_str; + +#[test] +fn skip_attr_on_statement_position_block_suppresses_nested_mutants() { + let mutants = mutate_source_str( + indoc! {r#" + fn driver(a: i32, b: i32, c: i32, d: i32) { + #[mutants::skip] + { + let _ = a + b; + } + let _ = c - d; + } + "#}, + &Options::default(), + ) + .unwrap(); + let names: Vec = mutants.iter().map(|m| m.name(false)).collect(); + + assert!( + !names.iter().any(|n| n.contains("replace + with")), + "`+` inside skipped block should not produce mutants: {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("replace - with")), + "`-` in the unannotated sibling code should still produce mutants: {names:?}" + ); +} + +#[test] +fn skip_attr_on_expression_position_block_suppresses_nested_mutants() { + let mutants = mutate_source_str( + indoc! {r#" + fn driver(a: i32, b: i32, c: i32, d: i32) -> i32 { + let x = #[mutants::skip] { + a + b + }; + let y = { + c - d + }; + x | y + } + "#}, + &Options::default(), + ) + .unwrap(); + let names: Vec = mutants.iter().map(|m| m.name(false)).collect(); + + assert!( + !names.iter().any(|n| n.contains("replace + with")), + "`+` inside skipped block should not produce mutants: {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("replace - with")), + "`-` in the unannotated sibling block should still produce mutants: {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("replace | with")), + "`|` in the unannotated tail expression should still produce mutants: {names:?}" + ); +} + +#[test] +fn skip_attr_on_block_suppresses_all_genres_within() { + let mutants = mutate_source_str( + indoc! {r#" + fn pick(x: i32, y: i32) -> &'static str { + #[mutants::skip] + { + let _ = !true; + match x { + 0 => "zero", + n if n > y => "gt", + _ => "other", + } + } + } + "#}, + &Options::default(), + ) + .unwrap(); + let names: Vec = mutants.iter().map(|m| m.name(false)).collect(); + + assert!( + !names.iter().any(|n| n.contains("delete !")), + "unary mutants inside skipped block should be suppressed: {names:?}" + ); + assert!( + !names.iter().any(|n| n.contains("delete match arm")), + "match arm deletion mutants inside skipped block should be suppressed: {names:?}" + ); + assert!( + !names.iter().any(|n| n.contains("replace match guard")), + "match guard mutants inside skipped block should be suppressed: {names:?}" + ); + assert!( + !names.iter().any(|n| n.contains("replace > with")), + "binary mutants inside skipped block should be suppressed: {names:?}" + ); +} + +#[test] +fn skip_attr_on_labeled_block_suppresses_nested_mutants() { + let mutants = mutate_source_str( + indoc! {r#" + fn driver(a: i32, b: i32) -> i32 { + #[mutants::skip] + 'block: { + if a > b { + break 'block a + b; + } + a - b + } + } + "#}, + &Options::default(), + ) + .unwrap(); + let names: Vec = mutants.iter().map(|m| m.name(false)).collect(); + + assert!( + !names.iter().any(|n| n.contains("replace + with")), + "`+` inside skipped labeled block should not produce mutants: {names:?}" + ); + assert!( + !names.iter().any(|n| n.contains("replace - with")), + "`-` inside skipped labeled block should not produce mutants: {names:?}" + ); + assert!( + !names.iter().any(|n| n.contains("replace > with")), + "`>` inside skipped labeled block should not produce mutants: {names:?}" + ); +}