diff --git a/NEWS.md b/NEWS.md index a764c7d6..5fbf68d4 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,9 @@ # cargo-mutants changelog +## Unreleased + +- 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 Released 2026-06-02. diff --git a/book/src/attrs.md b/book/src/attrs.md index 63a4fe1f..e5639186 100644 --- a/book/src/attrs.md +++ b/book/src/attrs.md @@ -59,6 +59,9 @@ mod test { - **`trait` blocks** — applies to all default method implementations. - **`mod` blocks** — applies to all items within the module. - **Files** (as an inner attribute `#![mutants::skip]`) — applies to the entire file. +- **`const` and `static` items** — applies to mutations generated from + inside the initializer expression. This also covers associated constants + declared inside `impl` and `trait` blocks. - **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 diff --git a/src/visit.rs b/src/visit.rs index 2dfdaec4..71d8b791 100644 --- a/src/visit.rs +++ b/src/visit.rs @@ -497,6 +497,77 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> { } } + /// Visit a top-level `const FOO: T = ...;` item. + /// + /// The visitor itself does not generate const-specific mutants, but it + /// honours `#[mutants::skip]` on the item so that operator mutants inside + /// the initializer expression can be suppressed. + fn visit_item_const(&mut self, i: &'ast syn::ItemConst) { + let _span = trace_span!( + "const", + line = i.const_token.span.start().line, + name = i.ident.to_pretty_string() + ) + .entered(); + if attrs_excluded(&i.attrs) { + trace!("const excluded by attrs"); + return; + } + syn::visit::visit_item_const(self, i); + } + + /// Visit a top-level `static FOO: T = ...;` item. + /// + /// As with [`Self::visit_item_const`], this only exists so that + /// `#[mutants::skip]` on the item suppresses mutants inside the + /// initializer expression. + fn visit_item_static(&mut self, i: &'ast syn::ItemStatic) { + let _span = trace_span!( + "static", + line = i.static_token.span.start().line, + name = i.ident.to_pretty_string() + ) + .entered(); + if attrs_excluded(&i.attrs) { + trace!("static excluded by attrs"); + return; + } + syn::visit::visit_item_static(self, i); + } + + /// Visit an associated `const FOO: T = ...;` inside an `impl` block. + fn visit_impl_item_const(&mut self, i: &'ast syn::ImplItemConst) { + let _span = trace_span!( + "const", + line = i.const_token.span.start().line, + name = i.ident.to_pretty_string() + ) + .entered(); + if attrs_excluded(&i.attrs) { + trace!("associated const excluded by attrs"); + return; + } + syn::visit::visit_impl_item_const(self, i); + } + + /// Visit an associated `const FOO: T [= ...];` inside a `trait` block. + /// + /// Only the default-value expression (if present) contains anything that + /// can be mutated; this method gates that descent on `#[mutants::skip]`. + fn visit_trait_item_const(&mut self, i: &'ast syn::TraitItemConst) { + let _span = trace_span!( + "const", + line = i.const_token.span.start().line, + name = i.ident.to_pretty_string() + ) + .entered(); + if attrs_excluded(&i.attrs) { + trace!("trait associated const excluded by attrs"); + return; + } + syn::visit::visit_trait_item_const(self, i); + } + /// Visit `impl Foo { ...}` or `impl Debug for Foo { ... }`. fn visit_item_impl(&mut self, i: &'ast syn::ItemImpl) { if attrs_excluded(&i.attrs) { @@ -991,7 +1062,11 @@ mod test { mod skip_attr_expr_unary; mod skip_attr_file; mod skip_attr_impl; + mod skip_attr_impl_item_const; + mod skip_attr_item_const; + mod skip_attr_item_static; mod skip_attr_trait; + mod skip_attr_trait_item_const; #[test] fn path_ends_with() { diff --git a/src/visit/test/skip_attr_impl_item_const.rs b/src/visit/test/skip_attr_impl_item_const.rs new file mode 100644 index 00000000..f3c29fcb --- /dev/null +++ b/src/visit/test/skip_attr_impl_item_const.rs @@ -0,0 +1,40 @@ +//! Tests that `#[mutants::skip]` on an associated `const` inside an `impl` +//! block suppresses mutants generated from inside its initializer expression. + +use indoc::indoc; +use test_log::test; + +use crate::Options; +use crate::mutant::Mutant; +use crate::visit::mutate_source_str; + +#[test] +fn skip_attr_on_impl_associated_const_suppresses_initializer_mutants() { + // Different operators on each associated const so the resulting mutants + // can be attributed unambiguously to their source item via + // `original_text()`, independently of line numbers or whitespace. + let mutants = mutate_source_str( + indoc! {r#" + pub struct S; + + impl S { + #[mutants::skip] + pub const SKIPPED_FLAGS: u32 = 0b0001 ^ 0b0010; + + pub const OTHER_FLAGS: u32 = 0b0100 | 0b1000; + } + "#}, + &Options::default(), + ) + .unwrap(); + let originals: Vec = mutants.iter().map(Mutant::original_text).collect(); + + assert!( + !originals.iter().any(|o| o == "^"), + "operators inside a skipped associated const should produce no mutants: {mutants:?}" + ); + assert!( + originals.iter().any(|o| o == "|"), + "sibling unskipped associated const should still produce mutants: {mutants:?}" + ); +} diff --git a/src/visit/test/skip_attr_item_const.rs b/src/visit/test/skip_attr_item_const.rs new file mode 100644 index 00000000..68811242 --- /dev/null +++ b/src/visit/test/skip_attr_item_const.rs @@ -0,0 +1,60 @@ +//! Tests that `#[mutants::skip]` on a top-level `const` item suppresses +//! mutants generated from inside its initializer expression. + +use indoc::indoc; +use test_log::test; + +use crate::Options; +use crate::mutant::Mutant; +use crate::visit::mutate_source_str; + +#[test] +fn skip_attr_on_item_const_suppresses_initializer_mutants() { + // Different operators on each const so the resulting mutants can be + // attributed unambiguously to their source item via `original_text()`, + // independently of line numbers or whitespace. + let mutants = mutate_source_str( + indoc! {r#" + #[mutants::skip] + pub const SKIPPED_FLAGS: u32 = 0b0001 ^ 0b0010; + + pub const OTHER_FLAGS: u32 = 0b0100 | 0b1000; + "#}, + &Options::default(), + ) + .unwrap(); + let originals: Vec = mutants.iter().map(Mutant::original_text).collect(); + + assert!( + !originals.iter().any(|o| o == "^"), + "operators inside a skipped const initializer should produce no mutants: {mutants:?}" + ); + assert!( + originals.iter().any(|o| o == "|"), + "sibling unskipped const should still produce mutants: {mutants:?}" + ); +} + +#[test] +fn cfg_attr_mutants_skip_on_item_const_suppresses_initializer_mutants() { + let mutants = mutate_source_str( + indoc! {r#" + #[cfg_attr(test, mutants::skip)] + pub const SKIPPED_FLAGS: u32 = 0b0001 ^ 0b0010; + + pub const OTHER_FLAGS: u32 = 0b0100 | 0b1000; + "#}, + &Options::default(), + ) + .unwrap(); + let originals: Vec = mutants.iter().map(Mutant::original_text).collect(); + + assert!( + !originals.iter().any(|o| o == "^"), + "operators inside a const skipped via cfg_attr should produce no mutants: {mutants:?}" + ); + assert!( + originals.iter().any(|o| o == "|"), + "sibling unskipped const should still produce mutants: {mutants:?}" + ); +} diff --git a/src/visit/test/skip_attr_item_static.rs b/src/visit/test/skip_attr_item_static.rs new file mode 100644 index 00000000..e42b5534 --- /dev/null +++ b/src/visit/test/skip_attr_item_static.rs @@ -0,0 +1,36 @@ +//! Tests that `#[mutants::skip]` on a top-level `static` item suppresses +//! mutants generated from inside its initializer expression. + +use indoc::indoc; +use test_log::test; + +use crate::Options; +use crate::mutant::Mutant; +use crate::visit::mutate_source_str; + +#[test] +fn skip_attr_on_item_static_suppresses_initializer_mutants() { + // Different operators on each static so the resulting mutants can be + // attributed unambiguously to their source item via `original_text()`, + // independently of line numbers or whitespace. + let mutants = mutate_source_str( + indoc! {r#" + #[mutants::skip] + pub static SKIPPED_FLAGS: u32 = 0b0001 ^ 0b0010; + + pub static OTHER_FLAGS: u32 = 0b0100 | 0b1000; + "#}, + &Options::default(), + ) + .unwrap(); + let originals: Vec = mutants.iter().map(Mutant::original_text).collect(); + + assert!( + !originals.iter().any(|o| o == "^"), + "operators inside a skipped static initializer should produce no mutants: {mutants:?}" + ); + assert!( + originals.iter().any(|o| o == "|"), + "sibling unskipped static should still produce mutants: {mutants:?}" + ); +} diff --git a/src/visit/test/skip_attr_trait_item_const.rs b/src/visit/test/skip_attr_trait_item_const.rs new file mode 100644 index 00000000..557f460e --- /dev/null +++ b/src/visit/test/skip_attr_trait_item_const.rs @@ -0,0 +1,39 @@ +//! Tests that `#[mutants::skip]` on an associated `const` inside a `trait` +//! declaration suppresses mutants generated from its default-value +//! expression. + +use indoc::indoc; +use test_log::test; + +use crate::Options; +use crate::mutant::Mutant; +use crate::visit::mutate_source_str; + +#[test] +fn skip_attr_on_trait_associated_const_default_suppresses_initializer_mutants() { + // Different operators on each associated const so the resulting mutants + // can be attributed unambiguously to their source item via + // `original_text()`, independently of line numbers or whitespace. + let mutants = mutate_source_str( + indoc! {r#" + pub trait Flags { + #[mutants::skip] + const SKIPPED_FLAGS: u32 = 0b0001 ^ 0b0010; + + const OTHER_FLAGS: u32 = 0b0100 | 0b1000; + } + "#}, + &Options::default(), + ) + .unwrap(); + let originals: Vec = mutants.iter().map(Mutant::original_text).collect(); + + assert!( + !originals.iter().any(|o| o == "^"), + "operators inside a skipped trait associated const default should produce no mutants: {mutants:?}" + ); + assert!( + originals.iter().any(|o| o == "|"), + "sibling unskipped trait associated const default should still produce mutants: {mutants:?}" + ); +}