mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-01 01:12:20 +00:00
Add unused-ignore-comment
rule (#15084)
This commit is contained in:
parent
dcb85b7088
commit
2a99c0be02
8 changed files with 284 additions and 61 deletions
|
@ -3,6 +3,7 @@ use std::hash::BuildHasherDefault;
|
|||
use rustc_hash::FxHasher;
|
||||
|
||||
use crate::lint::{LintRegistry, LintRegistryBuilder};
|
||||
use crate::suppression::UNUSED_IGNORE_COMMENT;
|
||||
pub use db::Db;
|
||||
pub use module_name::ModuleName;
|
||||
pub use module_resolver::{resolve_module, system_module_search_paths, KnownModule, Module};
|
||||
|
@ -47,4 +48,5 @@ pub fn default_lint_registry() -> &'static LintRegistry {
|
|||
/// Register all known semantic lints.
|
||||
pub fn register_lints(registry: &mut LintRegistryBuilder) {
|
||||
types::register_lints(registry);
|
||||
registry.register_lint(&UNUSED_IGNORE_COMMENT);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,39 @@
|
|||
use crate::lint::{Level, LintRegistry, LintStatus};
|
||||
use crate::types::{TypeCheckDiagnostic, TypeCheckDiagnostics};
|
||||
use crate::{declare_lint, lint::LintId, Db};
|
||||
use ruff_db::diagnostic::DiagnosticId;
|
||||
use ruff_db::{files::File, parsed::parsed_module, source::source_text};
|
||||
use ruff_python_parser::TokenKind;
|
||||
use ruff_python_trivia::Cursor;
|
||||
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use std::fmt;
|
||||
use std::fmt::Formatter;
|
||||
|
||||
use crate::lint::LintRegistry;
|
||||
use crate::{lint::LintId, Db};
|
||||
declare_lint! {
|
||||
/// ## What it does
|
||||
/// Checks for `type: ignore` or `knot: ignore` directives that are no longer applicable.
|
||||
///
|
||||
/// ## Why is this bad?
|
||||
/// A `type: ignore` directive that no longer matches any diagnostic violations is likely
|
||||
/// included by mistake, and should be removed to avoid confusion.
|
||||
///
|
||||
/// ## Examples
|
||||
/// ```py
|
||||
/// a = 20 / 2 # knot: ignore[division-by-zero]
|
||||
/// ```
|
||||
///
|
||||
/// Use instead:
|
||||
///
|
||||
/// ```py
|
||||
/// a = 20 / 2
|
||||
/// ```
|
||||
pub(crate) static UNUSED_IGNORE_COMMENT = {
|
||||
summary: "detects unused `type: ignore` comments",
|
||||
status: LintStatus::preview("1.0.0"),
|
||||
default_level: Level::Warn,
|
||||
}
|
||||
}
|
||||
|
||||
#[salsa::tracked(return_ref)]
|
||||
pub(crate) fn suppressions(db: &dyn Db, file: File) -> Suppressions {
|
||||
|
@ -25,7 +53,7 @@ pub(crate) fn suppressions(db: &dyn Db, file: File) -> Suppressions {
|
|||
let parser = SuppressionParser::new(&source, token.range());
|
||||
|
||||
for comment in parser {
|
||||
builder.add_comment(comment, line_start);
|
||||
builder.add_comment(&comment, TextRange::new(line_start, token.end()));
|
||||
}
|
||||
}
|
||||
TokenKind::Newline | TokenKind::NonLogicalNewline => {
|
||||
|
@ -38,6 +66,89 @@ pub(crate) fn suppressions(db: &dyn Db, file: File) -> Suppressions {
|
|||
builder.finish()
|
||||
}
|
||||
|
||||
/// Checks for unused suppression comments in `file` and
|
||||
/// adds diagnostic for each of them to `diagnostics`.
|
||||
///
|
||||
/// Does nothing if the [`UNUSED_IGNORE_COMMENT`] rule is disabled.
|
||||
pub(crate) fn check_unused_suppressions(
|
||||
db: &dyn Db,
|
||||
file: File,
|
||||
diagnostics: &mut TypeCheckDiagnostics,
|
||||
) {
|
||||
let Some(severity) = db
|
||||
.rule_selection()
|
||||
.severity(LintId::of(&UNUSED_IGNORE_COMMENT))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let all = suppressions(db, file);
|
||||
let mut unused = Vec::with_capacity(
|
||||
all.file
|
||||
.len()
|
||||
.saturating_add(all.line.len())
|
||||
.saturating_sub(diagnostics.used_len()),
|
||||
);
|
||||
|
||||
// Collect all suppressions that are unused after type-checking.
|
||||
for suppression in all {
|
||||
if diagnostics.is_used(suppression.id()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// `unused-ignore-comment` diagnostics can only be suppressed by specifying a
|
||||
// code. This is necessary because every `type: ignore` would implicitly also
|
||||
// suppress its own unused-ignore-comment diagnostic.
|
||||
if let Some(unused_suppression) = all
|
||||
.lint_suppressions(suppression.range, LintId::of(&UNUSED_IGNORE_COMMENT))
|
||||
.find(|unused_ignore_suppression| unused_ignore_suppression.target.is_lint())
|
||||
{
|
||||
// A `unused-ignore-comment` suppression can't ignore itself.
|
||||
// It can only ignore other suppressions.
|
||||
if unused_suppression.id() != suppression.id() {
|
||||
diagnostics.mark_used(unused_suppression.id());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
unused.push(suppression);
|
||||
}
|
||||
|
||||
for suppression in unused {
|
||||
// This looks silly but it's necessary to check again if a `unused-ignore-comment` is indeed unused
|
||||
// in case the "unused" directive comes after it:
|
||||
// ```py
|
||||
// a = 10 / 2 # knot: ignore[unused-ignore-comment, division-by-zero]
|
||||
// ```
|
||||
if diagnostics.is_used(suppression.id()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = match suppression.target {
|
||||
SuppressionTarget::All => {
|
||||
format!("Unused blanket `{}` directive", suppression.kind)
|
||||
}
|
||||
SuppressionTarget::Lint(lint) => {
|
||||
format!(
|
||||
"Unused `{kind}` directive: '{code}'",
|
||||
kind = suppression.kind,
|
||||
code = lint.name()
|
||||
)
|
||||
}
|
||||
SuppressionTarget::Empty => {
|
||||
format!("Unused `{}` without a code", suppression.kind)
|
||||
}
|
||||
};
|
||||
diagnostics.push(TypeCheckDiagnostic {
|
||||
id: DiagnosticId::Lint(UNUSED_IGNORE_COMMENT.name()),
|
||||
message,
|
||||
range: suppression.range,
|
||||
severity,
|
||||
file,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The suppressions of a single file.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct Suppressions {
|
||||
|
@ -59,10 +170,19 @@ pub(crate) struct Suppressions {
|
|||
|
||||
impl Suppressions {
|
||||
pub(crate) fn find_suppression(&self, range: TextRange, id: LintId) -> Option<&Suppression> {
|
||||
self.lint_suppressions(range, id).next()
|
||||
}
|
||||
|
||||
/// Returns all suppressions for the given lint
|
||||
fn lint_suppressions(
|
||||
&self,
|
||||
range: TextRange,
|
||||
id: LintId,
|
||||
) -> impl Iterator<Item = &Suppression> + '_ {
|
||||
self.file
|
||||
.iter()
|
||||
.chain(self.line_suppressions(range))
|
||||
.find(|suppression| suppression.matches(id))
|
||||
.filter(move |suppression| suppression.matches(id))
|
||||
}
|
||||
|
||||
/// Returns the line-level suppressions that apply for `range`.
|
||||
|
@ -94,6 +214,22 @@ impl Suppressions {
|
|||
|| suppression.suppressed_range.contains(range.end())
|
||||
})
|
||||
}
|
||||
|
||||
fn iter(&self) -> SuppressionsIter {
|
||||
self.file.iter().chain(&self.line)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) type SuppressionsIter<'a> =
|
||||
std::iter::Chain<std::slice::Iter<'a, Suppression>, std::slice::Iter<'a, Suppression>>;
|
||||
|
||||
impl<'a> IntoIterator for &'a Suppressions {
|
||||
type Item = &'a Suppression;
|
||||
type IntoIter = SuppressionsIter<'a>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// A `type: ignore` or `knot: ignore` suppression.
|
||||
|
@ -104,6 +240,7 @@ impl Suppressions {
|
|||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct Suppression {
|
||||
target: SuppressionTarget,
|
||||
kind: SuppressionKind,
|
||||
|
||||
/// The range of this specific suppression.
|
||||
/// This is the same as `comment_range` except for suppression comments that suppress multiple
|
||||
|
@ -127,10 +264,24 @@ impl Suppression {
|
|||
match self.target {
|
||||
SuppressionTarget::All => true,
|
||||
SuppressionTarget::Lint(suppressed_id) => tested_id == suppressed_id,
|
||||
SuppressionTarget::Empty => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn id(&self) -> FileSuppressionId {
|
||||
FileSuppressionId(self.range)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique ID for a suppression in a file.
|
||||
///
|
||||
/// ## Implementation
|
||||
/// The wrapped `TextRange` is the suppression's range.
|
||||
/// This is unique enough because it is its exact
|
||||
/// location in the source.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
|
||||
pub(crate) struct FileSuppressionId(TextRange);
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
enum SuppressionTarget {
|
||||
/// Suppress all lints
|
||||
|
@ -138,6 +289,15 @@ enum SuppressionTarget {
|
|||
|
||||
/// Suppress the lint with the given id
|
||||
Lint(LintId),
|
||||
|
||||
/// Suppresses no lint, e.g. `knot: ignore[]`
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl SuppressionTarget {
|
||||
const fn is_lint(self) -> bool {
|
||||
matches!(self, SuppressionTarget::Lint(_))
|
||||
}
|
||||
}
|
||||
|
||||
struct SuppressionsBuilder<'a> {
|
||||
|
@ -177,7 +337,7 @@ impl<'a> SuppressionsBuilder<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
fn add_comment(&mut self, comment: SuppressionComment, line_start: TextSize) {
|
||||
fn add_comment(&mut self, comment: &SuppressionComment, line_range: TextRange) {
|
||||
let (suppressions, suppressed_range) =
|
||||
// `type: ignore` comments at the start of the file apply to the entire range.
|
||||
// > A # type: ignore comment on a line by itself at the top of a file, before any docstrings,
|
||||
|
@ -193,15 +353,16 @@ impl<'a> SuppressionsBuilder<'a> {
|
|||
} else {
|
||||
(
|
||||
&mut self.line,
|
||||
TextRange::new(line_start, comment.range.end()),
|
||||
line_range,
|
||||
)
|
||||
};
|
||||
|
||||
match comment.codes {
|
||||
match comment.codes.as_deref() {
|
||||
// `type: ignore`
|
||||
None => {
|
||||
suppressions.push(Suppression {
|
||||
target: SuppressionTarget::All,
|
||||
kind: comment.kind,
|
||||
comment_range: comment.range,
|
||||
range: comment.range,
|
||||
suppressed_range,
|
||||
|
@ -214,15 +375,27 @@ impl<'a> SuppressionsBuilder<'a> {
|
|||
Some(_) if comment.kind.is_type_ignore() => {
|
||||
suppressions.push(Suppression {
|
||||
target: SuppressionTarget::All,
|
||||
kind: comment.kind,
|
||||
comment_range: comment.range,
|
||||
range: comment.range,
|
||||
suppressed_range,
|
||||
});
|
||||
}
|
||||
|
||||
// `knot: ignore[]`
|
||||
Some([]) => {
|
||||
suppressions.push(Suppression {
|
||||
target: SuppressionTarget::Empty,
|
||||
kind: comment.kind,
|
||||
range: comment.range,
|
||||
comment_range: comment.range,
|
||||
suppressed_range,
|
||||
});
|
||||
}
|
||||
|
||||
// `knot: ignore[a, b]`
|
||||
Some(codes) => {
|
||||
for code_range in &codes {
|
||||
for code_range in codes {
|
||||
let code = &self.source[*code_range];
|
||||
match self.lint_registry.get(code) {
|
||||
Ok(lint) => {
|
||||
|
@ -234,6 +407,7 @@ impl<'a> SuppressionsBuilder<'a> {
|
|||
|
||||
suppressions.push(Suppression {
|
||||
target: SuppressionTarget::Lint(lint),
|
||||
kind: comment.kind,
|
||||
range,
|
||||
comment_range: comment.range,
|
||||
suppressed_range,
|
||||
|
@ -448,6 +622,15 @@ impl SuppressionKind {
|
|||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SuppressionKind {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SuppressionKind::TypeIgnore => f.write_str("type: ignore"),
|
||||
SuppressionKind::Knot => f.write_str("knot: ignore"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::suppression::{SuppressionComment, SuppressionParser};
|
||||
|
|
|
@ -27,6 +27,7 @@ use crate::semantic_index::{
|
|||
DeclarationsIterator,
|
||||
};
|
||||
use crate::stdlib::{builtins_symbol, known_module_symbol, typing_extensions_symbol};
|
||||
use crate::suppression::check_unused_suppressions;
|
||||
use crate::symbol::{Boundness, Symbol};
|
||||
use crate::types::call::{CallDunderResult, CallOutcome};
|
||||
use crate::types::class_base::ClassBase;
|
||||
|
@ -65,6 +66,8 @@ pub fn check_types(db: &dyn Db, file: File) -> TypeCheckDiagnostics {
|
|||
diagnostics.extend(result.diagnostics());
|
||||
}
|
||||
|
||||
check_unused_suppressions(db, file, &mut diagnostics);
|
||||
|
||||
diagnostics
|
||||
}
|
||||
|
||||
|
|
|
@ -58,9 +58,7 @@ impl<'db> InferContext<'db> {
|
|||
where
|
||||
T: WithDiagnostics,
|
||||
{
|
||||
self.diagnostics
|
||||
.get_mut()
|
||||
.extend(other.diagnostics().iter().cloned());
|
||||
self.diagnostics.get_mut().extend(other.diagnostics());
|
||||
}
|
||||
|
||||
/// Reports a lint located at `node`.
|
||||
|
@ -68,7 +66,7 @@ impl<'db> InferContext<'db> {
|
|||
&self,
|
||||
lint: &'static LintMetadata,
|
||||
node: AnyNodeRef,
|
||||
message: std::fmt::Arguments,
|
||||
message: fmt::Arguments,
|
||||
) {
|
||||
// Skip over diagnostics if the rule is disabled.
|
||||
let Some(severity) = self.db.rule_selection().severity(LintId::of(lint)) else {
|
||||
|
@ -77,10 +75,8 @@ impl<'db> InferContext<'db> {
|
|||
|
||||
let suppressions = suppressions(self.db, self.file);
|
||||
|
||||
if suppressions
|
||||
.find_suppression(node.range(), LintId::of(lint))
|
||||
.is_some()
|
||||
{
|
||||
if let Some(suppression) = suppressions.find_suppression(node.range(), LintId::of(lint)) {
|
||||
self.diagnostics.borrow_mut().mark_used(suppression.id());
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -95,7 +91,7 @@ impl<'db> InferContext<'db> {
|
|||
node: AnyNodeRef,
|
||||
id: DiagnosticId,
|
||||
severity: Severity,
|
||||
message: std::fmt::Arguments,
|
||||
message: fmt::Arguments,
|
||||
) {
|
||||
if !self.db.is_file_open(self.file) {
|
||||
return;
|
||||
|
@ -106,7 +102,6 @@ impl<'db> InferContext<'db> {
|
|||
// * The rule is disabled for this file. We probably want to introduce a new query that
|
||||
// returns a rule selector for a given file that respects the package's settings,
|
||||
// any global pragma comments in the file, and any per-file-ignores.
|
||||
// * Check for suppression comments, bump a counter if the diagnostic is suppressed.
|
||||
|
||||
self.diagnostics.borrow_mut().push(TypeCheckDiagnostic {
|
||||
file: self.file,
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
use super::context::InferContext;
|
||||
use crate::declare_lint;
|
||||
use crate::lint::{Level, LintRegistryBuilder, LintStatus};
|
||||
use crate::suppression::FileSuppressionId;
|
||||
use crate::types::string_annotation::{
|
||||
BYTE_STRING_TYPE_ANNOTATION, ESCAPE_CHARACTER_IN_FORWARD_ANNOTATION, FSTRING_TYPE_ANNOTATION,
|
||||
IMPLICIT_CONCATENATED_STRING_TYPE_ANNOTATION, INVALID_SYNTAX_IN_FORWARD_ANNOTATION,
|
||||
|
@ -10,13 +12,12 @@ use ruff_db::diagnostic::{Diagnostic, DiagnosticId, Severity};
|
|||
use ruff_db::files::File;
|
||||
use ruff_python_ast::{self as ast, AnyNodeRef};
|
||||
use ruff_text_size::{Ranged, TextRange};
|
||||
use rustc_hash::FxHashSet;
|
||||
use std::borrow::Cow;
|
||||
use std::fmt::Formatter;
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::context::InferContext;
|
||||
|
||||
/// Registers all known type check lints.
|
||||
pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) {
|
||||
registry.register_lint(&CALL_NON_CALLABLE);
|
||||
|
@ -512,11 +513,11 @@ declare_lint! {
|
|||
|
||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||
pub struct TypeCheckDiagnostic {
|
||||
pub(super) id: DiagnosticId,
|
||||
pub(super) message: String,
|
||||
pub(super) range: TextRange,
|
||||
pub(super) severity: Severity,
|
||||
pub(super) file: File,
|
||||
pub(crate) id: DiagnosticId,
|
||||
pub(crate) message: String,
|
||||
pub(crate) range: TextRange,
|
||||
pub(crate) severity: Severity,
|
||||
pub(crate) file: File,
|
||||
}
|
||||
|
||||
impl TypeCheckDiagnostic {
|
||||
|
@ -570,41 +571,41 @@ impl Ranged for TypeCheckDiagnostic {
|
|||
/// each Salsa-struct comes with an overhead.
|
||||
#[derive(Default, Eq, PartialEq)]
|
||||
pub struct TypeCheckDiagnostics {
|
||||
inner: Vec<std::sync::Arc<TypeCheckDiagnostic>>,
|
||||
diagnostics: Vec<Arc<TypeCheckDiagnostic>>,
|
||||
used_suppressions: FxHashSet<FileSuppressionId>,
|
||||
}
|
||||
|
||||
impl TypeCheckDiagnostics {
|
||||
pub(super) fn push(&mut self, diagnostic: TypeCheckDiagnostic) {
|
||||
self.inner.push(Arc::new(diagnostic));
|
||||
pub(crate) fn push(&mut self, diagnostic: TypeCheckDiagnostic) {
|
||||
self.diagnostics.push(Arc::new(diagnostic));
|
||||
}
|
||||
|
||||
pub(super) fn extend(&mut self, other: &TypeCheckDiagnostics) {
|
||||
self.diagnostics.extend_from_slice(&other.diagnostics);
|
||||
self.used_suppressions.extend(&other.used_suppressions);
|
||||
}
|
||||
|
||||
pub(crate) fn mark_used(&mut self, suppression_id: FileSuppressionId) {
|
||||
self.used_suppressions.insert(suppression_id);
|
||||
}
|
||||
|
||||
pub(crate) fn is_used(&self, suppression_id: FileSuppressionId) -> bool {
|
||||
self.used_suppressions.contains(&suppression_id)
|
||||
}
|
||||
|
||||
pub(crate) fn used_len(&self) -> usize {
|
||||
self.used_suppressions.len()
|
||||
}
|
||||
|
||||
pub(crate) fn shrink_to_fit(&mut self) {
|
||||
self.inner.shrink_to_fit();
|
||||
}
|
||||
}
|
||||
|
||||
impl Extend<TypeCheckDiagnostic> for TypeCheckDiagnostics {
|
||||
fn extend<T: IntoIterator<Item = TypeCheckDiagnostic>>(&mut self, iter: T) {
|
||||
self.inner.extend(iter.into_iter().map(std::sync::Arc::new));
|
||||
}
|
||||
}
|
||||
|
||||
impl Extend<std::sync::Arc<TypeCheckDiagnostic>> for TypeCheckDiagnostics {
|
||||
fn extend<T: IntoIterator<Item = Arc<TypeCheckDiagnostic>>>(&mut self, iter: T) {
|
||||
self.inner.extend(iter);
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Extend<&'a std::sync::Arc<TypeCheckDiagnostic>> for TypeCheckDiagnostics {
|
||||
fn extend<T: IntoIterator<Item = &'a Arc<TypeCheckDiagnostic>>>(&mut self, iter: T) {
|
||||
self.inner
|
||||
.extend(iter.into_iter().map(std::sync::Arc::clone));
|
||||
self.used_suppressions.shrink_to_fit();
|
||||
self.diagnostics.shrink_to_fit();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TypeCheckDiagnostics {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
self.inner.fmt(f)
|
||||
self.diagnostics.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -612,7 +613,7 @@ impl Deref for TypeCheckDiagnostics {
|
|||
type Target = [std::sync::Arc<TypeCheckDiagnostic>];
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.inner
|
||||
&self.diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -621,7 +622,7 @@ impl IntoIterator for TypeCheckDiagnostics {
|
|||
type IntoIter = std::vec::IntoIter<std::sync::Arc<TypeCheckDiagnostic>>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.inner.into_iter()
|
||||
self.diagnostics.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -630,7 +631,7 @@ impl<'a> IntoIterator for &'a TypeCheckDiagnostics {
|
|||
type IntoIter = std::slice::Iter<'a, std::sync::Arc<TypeCheckDiagnostic>>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.inner.iter()
|
||||
self.diagnostics.iter()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue