From 7a2cb013ff784a3e6ed6c928ebd7484e716ad03d Mon Sep 17 00:00:00 2001 From: Sander Saares Date: Sun, 7 Jun 2026 11:22:03 +0300 Subject: [PATCH] Honor #[mutants::skip] on const and static items Previously, #[mutants::skip] (and #[cfg_attr(..., mutants::skip)]) placed on a const or static item was silently ignored: the visitor walked into the initializer expression and generated the usual operator mutants. Add visit_item_const, visit_item_static, visit_impl_item_const, and visit_trait_item_const to DiscoveryVisitor. Each bails out when the item's attrs match attrs_excluded, suppressing all mutants inside the initializer; otherwise it delegates to the default syn::visit recursion so behavior for unskipped items is unchanged. This addresses #508. Generating fresh "replace value" mutants for the constant itself (kpreid's follow-up suggestion) is a separate, larger feature and is not included here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- NEWS.md | 4 ++ book/src/attrs.md | 3 + src/visit.rs | 75 ++++++++++++++++++++ src/visit/test/skip_attr_impl_item_const.rs | 40 +++++++++++ src/visit/test/skip_attr_item_const.rs | 60 ++++++++++++++++ src/visit/test/skip_attr_item_static.rs | 36 ++++++++++ src/visit/test/skip_attr_trait_item_const.rs | 39 ++++++++++ 7 files changed, 257 insertions(+) create mode 100644 src/visit/test/skip_attr_impl_item_const.rs create mode 100644 src/visit/test/skip_attr_item_const.rs create mode 100644 src/visit/test/skip_attr_item_static.rs create mode 100644 src/visit/test/skip_attr_trait_item_const.rs 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:?}" + ); +}