Add invalid-ignore-comment rule (#15094)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / benchmarks (push) Blocked by required conditions

This commit is contained in:
Micha Reiser 2024-12-23 11:38:10 +01:00 committed by GitHub
parent 2835d94ec5
commit 8d327087ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 188 additions and 25 deletions

View file

@ -117,6 +117,7 @@ a = 10 / 0 # knot: ignore[division-by-zero,]
```py ```py
# error: [division-by-zero] # error: [division-by-zero]
# error: [invalid-ignore-comment] "Invalid `knot: ignore` comment: expected a alphanumeric character or `-` or `_` as code"
a = 10 / 0 # knot: ignore[*-*] a = 10 / 0 # knot: ignore[*-*]
``` ```
@ -138,9 +139,18 @@ future.
```py ```py
# error: [unresolved-reference] # error: [unresolved-reference]
# error: [invalid-ignore-comment] "Invalid `knot: ignore` comment: expected a comma separating the rule codes"
a = x / 0 # knot: ignore[division-by-zero unresolved-reference] a = x / 0 # knot: ignore[division-by-zero unresolved-reference]
``` ```
## Missing closing bracket
```py
# error: [unresolved-reference] "Name `x` used when not defined"
# error: [invalid-ignore-comment] "Invalid `knot: ignore` comment: expected a comma separating the rule codes"
a = x / 2 # knot: ignore[unresolved-reference
```
## Empty codes ## Empty codes
An empty codes array suppresses no-diagnostics and is always useless An empty codes array suppresses no-diagnostics and is always useless

View file

@ -110,6 +110,7 @@ a = test \
```py ```py
# error: [unresolved-reference] # error: [unresolved-reference]
# error: [invalid-ignore-comment]
a = test + 2 # type: ignoree a = test + 2 # type: ignoree
``` ```

View file

@ -3,7 +3,7 @@ use std::hash::BuildHasherDefault;
use rustc_hash::FxHasher; use rustc_hash::FxHasher;
use crate::lint::{LintRegistry, LintRegistryBuilder}; use crate::lint::{LintRegistry, LintRegistryBuilder};
use crate::suppression::{UNKNOWN_RULE, UNUSED_IGNORE_COMMENT}; use crate::suppression::{INVALID_IGNORE_COMMENT, UNKNOWN_RULE, UNUSED_IGNORE_COMMENT};
pub use db::Db; pub use db::Db;
pub use module_name::ModuleName; pub use module_name::ModuleName;
pub use module_resolver::{resolve_module, system_module_search_paths, KnownModule, Module}; pub use module_resolver::{resolve_module, system_module_search_paths, KnownModule, Module};
@ -50,4 +50,5 @@ pub fn register_lints(registry: &mut LintRegistryBuilder) {
types::register_lints(registry); types::register_lints(registry);
registry.register_lint(&UNUSED_IGNORE_COMMENT); registry.register_lint(&UNUSED_IGNORE_COMMENT);
registry.register_lint(&UNKNOWN_RULE); registry.register_lint(&UNKNOWN_RULE);
registry.register_lint(&INVALID_IGNORE_COMMENT);
} }

View file

@ -7,8 +7,10 @@ use ruff_python_parser::TokenKind;
use ruff_python_trivia::Cursor; use ruff_python_trivia::Cursor;
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use std::error::Error;
use std::fmt; use std::fmt;
use std::fmt::Formatter; use std::fmt::Formatter;
use thiserror::Error;
declare_lint! { declare_lint! {
/// ## What it does /// ## What it does
@ -60,6 +62,30 @@ declare_lint! {
} }
} }
declare_lint! {
/// ## What it does
/// Checks for `type: ignore` and `knot: ignore` comments that are syntactically incorrect.
///
/// ## Why is this bad?
/// A syntactically incorrect ignore comment is probably a mistake and is useless.
///
/// ## Examples
/// ```py
/// a = 20 / 0 # type: ignoree
/// ```
///
/// Use instead:
///
/// ```py
/// a = 20 / 0 # type: ignore
/// ```
pub(crate) static INVALID_IGNORE_COMMENT = {
summary: "detects ignore comments that use invalid syntax",
status: LintStatus::preview("1.0.0"),
default_level: Level::Warn,
}
}
#[salsa::tracked(return_ref)] #[salsa::tracked(return_ref)]
pub(crate) fn suppressions(db: &dyn Db, file: File) -> Suppressions { pub(crate) fn suppressions(db: &dyn Db, file: File) -> Suppressions {
let parsed = parsed_module(db.upcast(), file); let parsed = parsed_module(db.upcast(), file);
@ -78,8 +104,24 @@ pub(crate) fn suppressions(db: &dyn Db, file: File) -> Suppressions {
let parser = SuppressionParser::new(&source, token.range()); let parser = SuppressionParser::new(&source, token.range());
for comment in parser { for comment in parser {
match comment {
Ok(comment) => {
builder.add_comment(&comment, TextRange::new(line_start, token.end())); builder.add_comment(&comment, TextRange::new(line_start, token.end()));
} }
Err(error) => match error.kind {
ParseErrorKind::NotASuppression
| ParseErrorKind::CommentWithoutHash => {
// Skip non suppression comments and comments that miss a hash (shouldn't ever happen)
}
ParseErrorKind::NoWhitespaceAfterIgnore(kind)
| ParseErrorKind::CodesMissingComma(kind)
| ParseErrorKind::InvalidCode(kind)
| ParseErrorKind::CodesMissingClosingBracket(kind) => {
builder.add_invalid_comment(kind, error);
}
},
}
}
} }
TokenKind::Newline | TokenKind::NonLogicalNewline => { TokenKind::Newline | TokenKind::NonLogicalNewline => {
line_start = token.end(); line_start = token.end();
@ -95,6 +137,7 @@ pub(crate) fn check_suppressions(db: &dyn Db, file: File, diagnostics: &mut Type
let mut context = CheckSuppressionsContext::new(db, file, diagnostics); let mut context = CheckSuppressionsContext::new(db, file, diagnostics);
check_unknown_rule(&mut context); check_unknown_rule(&mut context);
check_invalid_suppression(&mut context);
check_unused_suppressions(&mut context); check_unused_suppressions(&mut context);
} }
@ -124,6 +167,24 @@ fn check_unknown_rule(context: &mut CheckSuppressionsContext) {
} }
} }
fn check_invalid_suppression(context: &mut CheckSuppressionsContext) {
if context.is_lint_disabled(&INVALID_IGNORE_COMMENT) {
return;
}
for invalid in &context.suppressions.invalid {
context.report_lint(
&INVALID_IGNORE_COMMENT,
invalid.error.range,
format_args!(
"Invalid `{kind}` comment: {reason}",
kind = invalid.kind,
reason = &invalid.error
),
);
}
}
/// Checks for unused suppression comments in `file` and /// Checks for unused suppression comments in `file` and
/// adds diagnostic for each of them to `diagnostics`. /// adds diagnostic for each of them to `diagnostics`.
/// ///
@ -246,6 +307,7 @@ impl<'a> CheckSuppressionsContext<'a> {
let Some(severity) = self.db.rule_selection().severity(LintId::of(lint)) else { let Some(severity) = self.db.rule_selection().severity(LintId::of(lint)) else {
return; return;
}; };
self.diagnostics.push(TypeCheckDiagnostic { self.diagnostics.push(TypeCheckDiagnostic {
id: DiagnosticId::Lint(lint.name()), id: DiagnosticId::Lint(lint.name()),
message: message.to_string(), message: message.to_string(),
@ -276,6 +338,9 @@ pub(crate) struct Suppressions {
/// Suppressions with lint codes that are unknown. /// Suppressions with lint codes that are unknown.
unknown: Vec<UnknownSuppression>, unknown: Vec<UnknownSuppression>,
/// Suppressions that are syntactically invalid.
invalid: Vec<InvalidSuppression>,
} }
impl Suppressions { impl Suppressions {
@ -421,6 +486,7 @@ struct SuppressionsBuilder<'a> {
line: Vec<Suppression>, line: Vec<Suppression>,
file: SmallVec<[Suppression; 1]>, file: SmallVec<[Suppression; 1]>,
unknown: Vec<UnknownSuppression>, unknown: Vec<UnknownSuppression>,
invalid: Vec<InvalidSuppression>,
} }
impl<'a> SuppressionsBuilder<'a> { impl<'a> SuppressionsBuilder<'a> {
@ -432,6 +498,7 @@ impl<'a> SuppressionsBuilder<'a> {
line: Vec::new(), line: Vec::new(),
file: SmallVec::new(), file: SmallVec::new(),
unknown: Vec::new(), unknown: Vec::new(),
invalid: Vec::new(),
} }
} }
@ -443,11 +510,13 @@ impl<'a> SuppressionsBuilder<'a> {
self.line.shrink_to_fit(); self.line.shrink_to_fit();
self.file.shrink_to_fit(); self.file.shrink_to_fit();
self.unknown.shrink_to_fit(); self.unknown.shrink_to_fit();
self.invalid.shrink_to_fit();
Suppressions { Suppressions {
file: self.file, file: self.file,
line: self.line, line: self.line,
unknown: self.unknown, unknown: self.unknown,
invalid: self.invalid,
} }
} }
@ -540,6 +609,10 @@ impl<'a> SuppressionsBuilder<'a> {
} }
} }
} }
fn add_invalid_comment(&mut self, kind: SuppressionKind, error: ParseError) {
self.invalid.push(InvalidSuppression { kind, error });
}
} }
/// Suppression for an unknown lint rule. /// Suppression for an unknown lint rule.
@ -554,6 +627,12 @@ struct UnknownSuppression {
reason: GetLintError, reason: GetLintError,
} }
#[derive(Debug, PartialEq, Eq)]
struct InvalidSuppression {
kind: SuppressionKind,
error: ParseError,
}
struct SuppressionParser<'src> { struct SuppressionParser<'src> {
cursor: Cursor<'src>, cursor: Cursor<'src>,
range: TextRange, range: TextRange,
@ -566,36 +645,41 @@ impl<'src> SuppressionParser<'src> {
Self { cursor, range } Self { cursor, range }
} }
fn parse_comment(&mut self) -> Option<SuppressionComment> { fn parse_comment(&mut self) -> Result<SuppressionComment, ParseError> {
let comment_start = self.offset(); let comment_start = self.offset();
self.cursor.start_token(); self.cursor.start_token();
if !self.cursor.eat_char('#') { if !self.cursor.eat_char('#') {
return None; return self.syntax_error(ParseErrorKind::CommentWithoutHash);
} }
self.eat_whitespace(); self.eat_whitespace();
// type: ignore[code] // type: ignore[code]
// ^^^^^^^^^^^^ // ^^^^^^^^^^^^
let kind = self.eat_kind()?; let Some(kind) = self.eat_kind() else {
return Err(ParseError::new(
ParseErrorKind::NotASuppression,
TextRange::new(comment_start, self.offset()),
));
};
let has_trailing_whitespace = self.eat_whitespace(); let has_trailing_whitespace = self.eat_whitespace();
// type: ignore[code1, code2] // type: ignore[code1, code2]
// ^^^^^^ // ^^^^^^
let codes = self.eat_codes(); let codes = self.eat_codes(kind)?;
if self.cursor.is_eof() || codes.is_some() || has_trailing_whitespace { if self.cursor.is_eof() || codes.is_some() || has_trailing_whitespace {
// Consume the comment until its end or until the next "sub-comment" starts. // Consume the comment until its end or until the next "sub-comment" starts.
self.cursor.eat_while(|c| c != '#'); self.cursor.eat_while(|c| c != '#');
Some(SuppressionComment { Ok(SuppressionComment {
kind, kind,
codes, codes,
range: TextRange::at(comment_start, self.cursor.token_len()), range: TextRange::at(comment_start, self.cursor.token_len()),
}) })
} else { } else {
None self.syntax_error(ParseErrorKind::NoWhitespaceAfterIgnore(kind))
} }
} }
@ -627,28 +711,31 @@ impl<'src> SuppressionParser<'src> {
Some(kind) Some(kind)
} }
fn eat_codes(&mut self) -> Option<SmallVec<[TextRange; 2]>> { fn eat_codes(
&mut self,
kind: SuppressionKind,
) -> Result<Option<SmallVec<[TextRange; 2]>>, ParseError> {
if !self.cursor.eat_char('[') { if !self.cursor.eat_char('[') {
return None; return Ok(None);
} }
let mut codes: SmallVec<[TextRange; 2]> = smallvec![]; let mut codes: SmallVec<[TextRange; 2]> = smallvec![];
loop { loop {
if self.cursor.is_eof() { if self.cursor.is_eof() {
return None; return self.syntax_error(ParseErrorKind::CodesMissingClosingBracket(kind));
} }
self.eat_whitespace(); self.eat_whitespace();
// `knot: ignore[]` or `knot: ignore[a,]` // `knot: ignore[]` or `knot: ignore[a,]`
if self.cursor.eat_char(']') { if self.cursor.eat_char(']') {
break Some(codes); break Ok(Some(codes));
} }
let code_start = self.offset(); let code_start = self.offset();
if !self.eat_word() { if !self.eat_word() {
return None; return self.syntax_error(ParseErrorKind::InvalidCode(kind));
} }
codes.push(TextRange::new(code_start, self.offset())); codes.push(TextRange::new(code_start, self.offset()));
@ -659,10 +746,10 @@ impl<'src> SuppressionParser<'src> {
self.eat_whitespace(); self.eat_whitespace();
if self.cursor.eat_char(']') { if self.cursor.eat_char(']') {
break Some(codes); break Ok(Some(codes));
} }
// `knot: ignore[a b] // `knot: ignore[a b]
return None; return self.syntax_error(ParseErrorKind::CodesMissingComma(kind));
} }
} }
} }
@ -686,25 +773,35 @@ impl<'src> SuppressionParser<'src> {
} }
} }
fn syntax_error<T>(&self, kind: ParseErrorKind) -> Result<T, ParseError> {
let len = if self.cursor.is_eof() {
TextSize::default()
} else {
self.cursor.first().text_len()
};
Err(ParseError::new(kind, TextRange::at(self.offset(), len)))
}
fn offset(&self) -> TextSize { fn offset(&self) -> TextSize {
self.range.start() + self.range.len() - self.cursor.text_len() self.range.start() + self.range.len() - self.cursor.text_len()
} }
} }
impl Iterator for SuppressionParser<'_> { impl Iterator for SuppressionParser<'_> {
type Item = SuppressionComment; type Item = Result<SuppressionComment, ParseError>;
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {
loop {
if self.cursor.is_eof() { if self.cursor.is_eof() {
return None; return None;
} }
if let Some(suppression) = self.parse_comment() { match self.parse_comment() {
return Some(suppression); Ok(result) => Some(Ok(result)),
} Err(error) => {
self.cursor.eat_while(|c| c != '#'); self.cursor.eat_while(|c| c != '#');
Some(Err(error))
}
} }
} }
} }
@ -761,6 +858,58 @@ impl fmt::Display for SuppressionKind {
} }
} }
#[derive(Debug, Eq, PartialEq, Clone)]
struct ParseError {
kind: ParseErrorKind,
/// The position/range at which the parse error occurred.
range: TextRange,
}
impl ParseError {
fn new(kind: ParseErrorKind, range: TextRange) -> Self {
Self { kind, range }
}
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
self.kind.fmt(f)
}
}
impl Error for ParseError {}
#[derive(Debug, Eq, PartialEq, Clone, Error)]
enum ParseErrorKind {
/// The comment isn't a suppression comment.
#[error("not a suppression comment")]
NotASuppression,
#[error("the comment doesn't start with a `#`")]
CommentWithoutHash,
/// A valid suppression `type: ignore` but it misses a whitespaces after the `ignore` keyword.
///
/// ```py
/// type: ignoree
/// ```
#[error("no whitespace after `ignore`")]
NoWhitespaceAfterIgnore(SuppressionKind),
/// Missing comma between two codes
#[error("expected a comma separating the rule codes")]
CodesMissingComma(SuppressionKind),
/// `knot: ignore[*.*]`
#[error("expected a alphanumeric character or `-` or `_` as code")]
InvalidCode(SuppressionKind),
/// `knot: ignore[a, b`
#[error("expected a closing bracket")]
CodesMissingClosingBracket(SuppressionKind),
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::suppression::{SuppressionComment, SuppressionParser}; use crate::suppression::{SuppressionComment, SuppressionParser};
@ -964,7 +1113,9 @@ mod tests {
for comment in SuppressionParser::new( for comment in SuppressionParser::new(
self.source, self.source,
TextRange::new(0.into(), self.source.text_len()), TextRange::new(0.into(), self.source.text_len()),
) { )
.flatten()
{
list.entry(&comment.debug(self.source)); list.entry(&comment.debug(self.source));
} }