use std::collections::{BTreeSet, HashSet};
use clippy_utils::diagnostics::{span_lint_and_help, span_lint_and_sugg};
use clippy_utils::is_from_proc_macro;
use clippy_utils::source::{indent_of, snippet_opt};
use rustc_ast::{AttrStyle, Attribute, Item, ItemKind, MetaItem, MetaItemInner, MetaItemKind};
use rustc_errors::Applicability;
use rustc_lint::{EarlyContext, EarlyLintPass, Lint, LintStore};
use rustc_session::{declare_tool_lint, impl_lint_pass};
use rustc_span::{Span, Symbol, sym};
use crate::common::{DefaultState, render_meta_path, resolve_string_set, resolved_state};
#[cfg(test)]
mod tests;
declare_tool_lint! {
pub perfectionist::PREFER_EXPECT_OVER_ALLOW,
Warn,
"`#[allow]` for a deterministically-firing lint should be `#[expect]`",
report_in_external_macro: false
}
pub(crate) const DEFAULT_STATE: DefaultState = DefaultState::Active;
const CONFIG_KEY: &str = "perfectionist::prefer_expect_over_allow";
const DEFAULT_EXEMPT_LINTS: &[&str] = &[
"dead_code",
"unused_imports",
"unused_macros",
"unused_variables",
"unused_mut",
"unused_assignments",
"unused_must_use",
"unreachable_code",
];
const CLIPPY_LINT_GROUPS: &[&str] = &[
"all",
"cargo",
"complexity",
"correctness",
"deprecated",
"nursery",
"pedantic",
"perf",
"restriction",
"style",
"suspicious",
];
const RUSTDOC_LINT_GROUPS: &[&str] = &["all"];
#[derive(Debug, serde::Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "snake_case")]
struct Config {
extra_exempt_lints: Vec<String>,
ignore_exempt_lints: Vec<String>,
apply_to_outer_scopes: bool,
apply_to_tool_namespaces: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
extra_exempt_lints: Vec::new(),
ignore_exempt_lints: Vec::new(),
apply_to_outer_scopes: false,
apply_to_tool_namespaces: true,
}
}
}
pub struct PreferExpectOverAllow {
exempt_lints: BTreeSet<String>,
apply_to_outer_scopes: bool,
apply_to_tool_namespaces: bool,
builtin_lints: HashSet<String>,
module_attr_spans: HashSet<Span>,
}
impl PreferExpectOverAllow {
fn new(builtin_lints: HashSet<String>) -> Self {
let config: Config = dylint_linting::config_or_default(CONFIG_KEY);
Self {
exempt_lints: resolve_string_set(
DEFAULT_EXEMPT_LINTS,
config.extra_exempt_lints,
config.ignore_exempt_lints,
),
apply_to_outer_scopes: config.apply_to_outer_scopes,
apply_to_tool_namespaces: config.apply_to_tool_namespaces,
builtin_lints,
module_attr_spans: HashSet::new(),
}
}
}
impl_lint_pass!(PreferExpectOverAllow => [PREFER_EXPECT_OVER_ALLOW]);
pub fn register_lint(lint_store: &mut LintStore) {
lint_store.register_lints(&[PREFER_EXPECT_OVER_ALLOW]);
}
pub fn register_pass(lint_store: &mut LintStore) {
if let DefaultState::Inactive = resolved_state("prefer_expect_over_allow", DEFAULT_STATE) {
return;
}
let builtin_lints = collect_builtin_lint_names(lint_store);
lint_store
.register_early_pass(move || Box::new(PreferExpectOverAllow::new(builtin_lints.clone())));
}
fn collect_builtin_lint_names(lint_store: &LintStore) -> HashSet<String> {
lint_store
.get_lints()
.iter()
.map(|lint: &&Lint| lint.name_lower())
.filter(|name| !name.contains("::"))
.collect()
}
const ALLOW: Symbol = sym::allow;
impl EarlyLintPass for PreferExpectOverAllow {
fn check_item(&mut self, _: &EarlyContext<'_>, item: &Item) {
if matches!(item.kind, ItemKind::Mod(..)) {
for attribute in &item.attrs {
self.module_attr_spans.insert(attribute.span);
}
}
}
fn check_attribute(&mut self, lint_context: &EarlyContext<'_>, attribute: &Attribute) {
if !attribute.has_name(ALLOW) {
return;
}
if is_from_proc_macro(lint_context, attribute) {
return;
}
if !self.scope_is_eligible(attribute.style, attribute.span) {
return;
}
let Some(ident_span) = attr_path_ident_span(attribute) else {
return;
};
let Some(args) = attribute.meta_item_list() else {
return;
};
self.check_allow(
lint_context,
ident_span,
attribute.span,
attribute.style,
&args,
);
}
}
enum Container {
Bare { span: Span, style: AttrStyle },
CfgAttrInner { span: Span },
}
impl PreferExpectOverAllow {
fn scope_is_eligible(&self, style: AttrStyle, span: Span) -> bool {
if self.apply_to_outer_scopes {
return true;
}
if let AttrStyle::Inner = style {
return false;
}
!self.module_attr_spans.contains(&span)
}
fn check_allow(
&self,
lint_context: &EarlyContext<'_>,
ident_span: Span,
attr_span: Span,
attr_style: AttrStyle,
args: &[MetaItemInner],
) {
let mut rewriteable: Vec<String> = Vec::new();
let mut kept: Vec<String> = Vec::new();
let mut reason: Option<&MetaItem> = None;
for arg in args {
let MetaItemInner::MetaItem(meta) = arg else {
continue;
};
match &meta.kind {
MetaItemKind::Word => {
let name = render_meta_path(meta);
if self.is_rewriteable(meta, &name) {
rewriteable.push(name);
} else {
kept.push(name);
}
}
MetaItemKind::NameValue(_) if meta.has_name(sym::reason) => {
reason = Some(meta);
}
_ => {}
}
}
if rewriteable.is_empty() {
return;
}
let reason_snippet = reason.and_then(|meta| snippet_opt(lint_context, meta.span));
if kept.is_empty() {
span_lint_and_sugg(
lint_context,
PREFER_EXPECT_OVER_ALLOW,
ident_span,
"this `#[allow]` can be `#[expect]` so the suppression self-cleans",
"replace `allow` with `expect`",
"expect".to_owned(),
Applicability::MaybeIncorrect,
);
return;
}
let reason_recoverable = reason.is_none() || reason_snippet.is_some();
match container_of(lint_context, attr_span, attr_style) {
Some(container) if reason_recoverable => self.emit_split(
lint_context,
container,
&kept,
&rewriteable,
reason_snippet.as_deref(),
),
_ => emit_split_without_fix(lint_context, ident_span),
}
}
fn is_rewriteable(&self, meta: &MetaItem, name: &str) -> bool {
if self.exempt_lints.contains(name) {
return false;
}
let segments = &meta.path.segments;
if segments.len() <= 1 {
return self.builtin_lints.contains(name);
}
let tool = segments[0].ident.name.as_str();
let lint = segments.last().map(|segment| segment.ident.name.as_str());
match tool {
"clippy" => lint.is_some_and(|lint| !CLIPPY_LINT_GROUPS.contains(&lint)),
"rustdoc" => lint.is_some_and(|lint| !RUSTDOC_LINT_GROUPS.contains(&lint)),
_ => self.apply_to_tool_namespaces,
}
}
fn emit_split(
&self,
lint_context: &EarlyContext<'_>,
container: Container,
kept: &[String],
rewriteable: &[String],
reason: Option<&str>,
) {
let allow_body = render_invocation("allow", kept, reason);
let expect_body = render_invocation("expect", rewriteable, reason);
let (span, replacement) = match container {
Container::CfgAttrInner { span } => (span, format!("{allow_body}, {expect_body}")),
Container::Bare { span, style } => {
let hash = match style {
AttrStyle::Inner => "#!",
AttrStyle::Outer => "#",
};
let pad = " ".repeat(indent_of(lint_context, span).unwrap_or(0));
(
span,
format!("{hash}[{allow_body}]\n{pad}{hash}[{expect_body}]"),
)
}
};
span_lint_and_sugg(
lint_context,
PREFER_EXPECT_OVER_ALLOW,
span,
"the deterministically-firing lints here can move to `#[expect]`",
"split the deterministic lints into a separate `#[expect]`",
replacement,
Applicability::MaybeIncorrect,
);
}
}
fn container_of(
lint_context: &EarlyContext<'_>,
span: Span,
style: AttrStyle,
) -> Option<Container> {
let snippet = snippet_opt(lint_context, span)?;
Some(if snippet.trim_start().starts_with('#') {
Container::Bare { span, style }
} else {
Container::CfgAttrInner { span }
})
}
fn emit_split_without_fix(lint_context: &EarlyContext<'_>, ident_span: Span) {
span_lint_and_help(
lint_context,
PREFER_EXPECT_OVER_ALLOW,
ident_span,
"the deterministically-firing lints here can move to `#[expect]`",
None,
"split the deterministic lints out into a separate `#[expect]`",
);
}
fn render_invocation(keyword: &str, names: &[String], reason: Option<&str>) -> String {
let mut parts: Vec<&str> = names.iter().map(String::as_str).collect();
if let Some(reason) = reason {
parts.push(reason);
}
format!("{keyword}({})", parts.join(", "))
}
fn attr_path_ident_span(attribute: &Attribute) -> Option<Span> {
let item = attribute.get_normal_item();
item.path.segments.first().map(|segment| segment.ident.span)
}