use std::collections::BTreeSet;
use std::num::NonZeroUsize;
use clippy_utils::diagnostics::{span_lint_and_sugg, span_lint_and_then};
use clippy_utils::is_from_proc_macro;
use rustc_ast::{Attribute, LitKind, MetaItem, MetaItemInner, MetaItemKind};
use rustc_errors::Applicability;
use rustc_lexer::{FrontmatterAllowed, TokenKind, tokenize};
use rustc_lint::{EarlyContext, EarlyLintPass, LintContext, LintStore};
use rustc_session::{declare_tool_lint, impl_lint_pass};
use rustc_span::{BytePos, Span, Symbol, sym};
use crate::common::{DefaultState, attr_has_reason, render_meta_path, resolved_state};
declare_tool_lint! {
pub perfectionist::LINT_SILENCE_REASON,
Warn,
r#"`#[allow]` / `#[expect]` attribute lacks an explanatory `reason = "..."` field"#,
report_in_external_macro: false
}
const CONFIG_KEY: &str = "perfectionist::lint_silence_reason";
#[derive(Debug, serde::Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "snake_case")]
struct Config {
exempt_lints: Vec<String>,
min_reason_length: NonZeroUsize,
}
const DEFAULT_MIN_REASON_LENGTH: NonZeroUsize = NonZeroUsize::new(3).expect("3 is non-zero");
impl Default for Config {
fn default() -> Self {
Self {
exempt_lints: Vec::new(),
min_reason_length: DEFAULT_MIN_REASON_LENGTH,
}
}
}
pub struct LintSilenceReason {
exempt_lints: BTreeSet<String>,
min_reason_length: NonZeroUsize,
}
impl LintSilenceReason {
fn new() -> Self {
let config: Config = dylint_linting::config_or_default(CONFIG_KEY);
Self {
exempt_lints: config.exempt_lints.into_iter().collect(),
min_reason_length: config.min_reason_length,
}
}
}
impl_lint_pass!(LintSilenceReason => [LINT_SILENCE_REASON]);
pub fn register_lint(lint_store: &mut LintStore) {
lint_store.register_lints(&[LINT_SILENCE_REASON]);
}
pub fn register_pass(lint_store: &mut LintStore) {
if let DefaultState::Inactive = resolved_state("lint_silence_reason", DefaultState::Active) {
return;
}
lint_store.register_early_pass(|| Box::new(LintSilenceReason::new()));
}
impl EarlyLintPass for LintSilenceReason {
fn check_attribute(&mut self, lint_context: &EarlyContext<'_>, attribute: &Attribute) {
let is_silencing = is_silencing_attribute_name(attribute.name());
if !is_silencing && !attribute.has_name(sym::cfg_attr) {
return;
}
if is_from_proc_macro(lint_context, attribute) {
return;
}
if is_silencing {
if let Some(args) = attribute.meta_item_list() {
self.check_silencing(lint_context, attribute.span, &args);
}
} else {
let Some(cfg_attr_args) = attribute.meta_item_list() else {
return;
};
for wrapped in cfg_attr_args.iter().skip(1) {
let Some(meta_item) = wrapped.meta_item() else {
continue;
};
self.walk_cfg_attr_inner(lint_context, meta_item);
}
}
}
}
const SILENCING_NAMES: [Symbol; 2] = [sym::allow, sym::expect];
fn is_silencing_attribute_name(name: Option<Symbol>) -> bool {
name.is_some_and(|name| SILENCING_NAMES.contains(&name))
}
fn is_silencing_meta_item(meta_item: &MetaItem) -> bool {
SILENCING_NAMES.iter().any(|name| meta_item.has_name(*name))
}
impl LintSilenceReason {
fn walk_cfg_attr_inner(&self, lint_context: &EarlyContext<'_>, meta_item: &MetaItem) {
if is_silencing_meta_item(meta_item) {
let MetaItemKind::List(args) = &meta_item.kind else {
return;
};
self.check_silencing(lint_context, meta_item.span, args);
return;
}
if meta_item.has_name(sym::cfg_attr) {
let MetaItemKind::List(cfg_attr_args) = &meta_item.kind else {
return;
};
for wrapped in cfg_attr_args.iter().skip(1) {
let Some(inner) = wrapped.meta_item() else {
continue;
};
self.walk_cfg_attr_inner(lint_context, inner);
}
}
}
}
impl LintSilenceReason {
fn check_silencing(
&self,
lint_context: &EarlyContext<'_>,
invocation_span: Span,
args: &[MetaItemInner],
) {
if self.every_named_lint_is_exempt(args) {
return;
}
match attr_has_reason(args) {
None => self.emit_missing_field(lint_context, invocation_span),
Some(literal) => {
let LitKind::Str(value, _) = literal.kind else {
return;
};
let value_str = value.as_str();
if value_str.trim().is_empty() {
self.emit_blank_reason(lint_context, literal.span);
return;
}
if value_str.chars().count() < self.min_reason_length.get() {
self.emit_too_short(lint_context, literal.span);
}
}
}
}
fn every_named_lint_is_exempt(&self, args: &[MetaItemInner]) -> bool {
for arg in args {
let MetaItemInner::MetaItem(meta) = arg else {
continue;
};
if matches!(meta.kind, MetaItemKind::NameValue(_)) {
continue;
}
let name = render_meta_path(meta);
if !self.exempt_lints.contains(&name) {
return false;
}
}
true
}
fn emit_missing_field(&self, lint_context: &EarlyContext<'_>, invocation_span: Span) {
let insertion = lint_context
.sess()
.source_map()
.span_to_snippet(invocation_span)
.ok()
.and_then(|snippet| build_insertion(&snippet));
let Some(Insertion {
start,
end,
replacement,
}) = insertion
else {
emit_missing_field_no_sugg(lint_context, invocation_span);
return;
};
let lo = invocation_span.lo() + BytePos(start as u32);
let hi = invocation_span.lo() + BytePos(end as u32);
let suggestion_span = invocation_span.with_lo(lo).with_hi(hi);
span_lint_and_sugg(
lint_context,
LINT_SILENCE_REASON,
suggestion_span,
r#"lint-silencing attribute lacks an explanatory `reason = "..."` field"#,
"add a `reason` field",
replacement,
Applicability::HasPlaceholders,
);
}
fn emit_blank_reason(&self, lint_context: &EarlyContext<'_>, literal_span: Span) {
span_lint_and_then(
lint_context,
LINT_SILENCE_REASON,
literal_span,
r#"lint-silencing attribute lacks an explanatory `reason = "..."` field"#,
|diagnostic| {
diagnostic.help("write a rationale into the blank `reason` literal");
},
);
}
fn emit_too_short(&self, lint_context: &EarlyContext<'_>, literal_span: Span) {
span_lint_and_then(
lint_context,
LINT_SILENCE_REASON,
literal_span,
"`reason` is shorter than the configured minimum",
|diagnostic| {
let min = self.min_reason_length.get();
diagnostic.help(format!(
"write a rationale of at least {min} character{}",
if min == 1 { "" } else { "s" },
));
},
);
}
}
fn emit_missing_field_no_sugg(lint_context: &EarlyContext<'_>, invocation_span: Span) {
span_lint_and_then(
lint_context,
LINT_SILENCE_REASON,
invocation_span,
r#"lint-silencing attribute lacks an explanatory `reason = "..."` field"#,
|diagnostic| {
diagnostic.help(r#"add a `reason = "..."` argument inside the attribute"#);
},
);
}
struct Insertion {
start: usize,
end: usize,
replacement: String,
}
fn locate_outermost_parens(snippet: &str) -> Option<(usize, usize)> {
let mut open: Option<usize> = None;
let mut depth: usize = 0;
let mut offset: usize = 0;
for token in tokenize(snippet, FrontmatterAllowed::No) {
let len = token.len as usize;
match token.kind {
TokenKind::OpenParen => {
if open.is_none() {
open = Some(offset);
}
depth += 1;
}
TokenKind::CloseParen => {
if depth == 0 {
return None;
}
depth -= 1;
if depth == 0 {
return open.map(|open_offset| (open_offset, offset));
}
}
_ => {}
}
offset += len;
}
None
}
fn build_insertion(snippet: &str) -> Option<Insertion> {
let (open_paren_offset, close_paren_offset) = locate_outermost_parens(snippet)?;
let head = &snippet[..close_paren_offset];
if !head[open_paren_offset..].contains('\n') {
let trimmed = head.trim_end_matches([' ', '\t', '\r']);
let has_trailing_comma = trimmed.ends_with(',');
let replacement = if has_trailing_comma {
r#" reason = "","#
} else {
r#", reason = """#
};
return Some(Insertion {
start: close_paren_offset,
end: close_paren_offset,
replacement: replacement.to_owned(),
});
}
let newline_before_close = head.rfind('\n')?;
let last_content_line_start = head[..newline_before_close]
.rfind('\n')
.map_or(open_paren_offset + 1, |index| index + 1);
let last_content_line = &head[last_content_line_start..newline_before_close];
let indent: String = last_content_line
.chars()
.take_while(|character| matches!(character, ' ' | '\t'))
.collect();
let last_content_trimmed = last_content_line.trim_end_matches([' ', '\t', '\r']);
if last_content_trimmed.ends_with(',') || last_content_trimmed.is_empty() {
let insertion = format!("{indent}reason = \"\",\n");
Some(Insertion {
start: newline_before_close + 1,
end: newline_before_close + 1,
replacement: insertion,
})
} else {
let trimmed_end = last_content_line_start + last_content_trimmed.len();
let replacement = format!(",\n{indent}reason = \"\",");
Some(Insertion {
start: trimmed_end,
end: newline_before_close,
replacement,
})
}
}
#[cfg(test)]
mod tests;