Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
3 changes: 3 additions & 0 deletions book/src/attrs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions src/visit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down
40 changes: 40 additions & 0 deletions src/visit/test/skip_attr_impl_item_const.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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:?}"
);
}
60 changes: 60 additions & 0 deletions src/visit/test/skip_attr_item_const.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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<String> = 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:?}"
);
}
36 changes: 36 additions & 0 deletions src/visit/test/skip_attr_item_static.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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:?}"
);
}
39 changes: 39 additions & 0 deletions src/visit/test/skip_attr_trait_item_const.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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:?}"
);
}
Loading