Clippy warns about dead code but expect also triggers a warning

I have the following weird situation:

#[expect(dead_code)]
mod unused {
    struct Unused;

    impl Clone for Unused {
        fn clone(&self) -> Self {
            todo!()
        }
    }
}

If I run cargo clippy on this I get:

warning: this lint expectation is unfulfilled
 --> src/lib.rs:1:10
  |
1 | #[expect(dead_code)]
  |          ^^^^^^^^^
  |
  = note: `#[warn(unfulfilled_lint_expectations)]` on by default

But If I remove the #[expect(dead_code)] I get:

warning: struct `Unused` is never constructed
 --> src/lib.rs:2:12
  |
2 |     struct Unused;
  |            ^^^^^^
  |
  = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default

If I turn the expect into an allow there are no warnings. Also interestingly, if I remove the impl Clone for Unused the expect also works. So seems like the expect has problems if there are trait implementations for the unused type.

Is this expected behaviour or have I found a bug?

You need to put the expect on the Unused struct itself and not the mod:

#![deny(dead_code, unfulfilled_lint_expectations, reason = "example")]
mod unused {
    #[expect(dead_code, reason = "example")]
    struct Unused;
    impl Clone for Unused {
        fn clone(&self) -> Self {
            todo!()
        }
    }
}
[zack@laptop foo]$ cargo check
    Checking foo v0.1.0 (/home/zack/projects/foo)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s

Thanks for the reply. Yes I agree that would be the right thing to do for this small example. In my real use case the struct gets generated through a macro so I cannot put it directly on the struct (I don't want it to put it inside the macro as for production use cases the type should always be used, the case where it is not used are just some tests of the macro).

So I agree putting it over the struct is better in the simple example, but shouldn't it also work if I put it over the module? I always thought #![expect(...)] always works when removing it would lead to some violations, but it seems like this is wrong for my example.

If you only want to allow/deny/expect/forbid/warn lints in certain environments (e.g., test), you should use cfg_attr instead; thus something like:

mod unused {
    #[cfg_attr(test, expect(dead_code, reason = "example"))]
    struct Unused;
    impl Clone for Unused {
        fn clone(&self) -> Self {
            todo!()
        }
    }
}

You are correct though that putting it above the mod should be fine. I found this bug report that seems similar.

I think the more general problem is about mod/crate level expect(dead_code) with at least one type that is not part of the public API as even something like below doesn't compile:

#![deny(dead_code, unfulfilled_lint_expectations, reason = "example")]
#![expect(dead_code, reason = "example")]
pub(crate) struct Foo;
impl Foo {}

dead_code is triggered but for some reason is not "remembered"; thus it triggers unfulfilled_lint_expectations.

I filed a bug on GitHub.

TIL you can put reason on lint allow/warn/expect/deny attributes.

It's just amazing to have never seen this before, then realize "of course, rust lets you document this".

Time to gradually add this to all of my projects!

1 Like

Indeed. I discovered that some time ago only because I'm one of the pariahs that globally denys all lints. In the clippy::restriction group, you have lints like clippy::allow_attributes_without_reason which fire when you don't provide a reason.

1 Like

Lint reasons and #[expect] were actually stabilized at the same time, in 1.81. So they are relatively new features, and there is lots of older code out there that doesn’t use reasons and just puts comments after their allows.

2 Likes