Add unused-ignore-comment rule (#15084)

This commit is contained in:
Micha Reiser 2024-12-23 11:15:28 +01:00 committed by GitHub
parent dcb85b7088
commit 2a99c0be02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 284 additions and 61 deletions

View file

@ -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);
}

View file

@ -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};

View file

@ -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
}

View file

@ -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,

View 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()
}
}