-
Notifications
You must be signed in to change notification settings - Fork 2k
Basic support for type: ignore comments
#15046
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
25fd417
95eec88
15eb044
5e2cd37
bd1145f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| # Suppressing errors with `type: ignore` | ||
|
|
||
| Type check errors can be suppressed by a `type: ignore` comment on the same line as the violation. | ||
|
|
||
| ## Simple `type: ignore` | ||
|
|
||
| ```py | ||
| a = 4 + test # type: ignore | ||
| ``` | ||
|
|
||
| ## Multiline ranges | ||
|
|
||
| A diagnostic with a multiline range can be suppressed by a comment on the same line as the | ||
| diagnostic's start or end. This is the same behavior as Mypy's. | ||
|
|
||
| ```py | ||
| # fmt: off | ||
| y = ( | ||
| 4 / 0 # type: ignore | ||
| ) | ||
|
|
||
| y = ( | ||
| 4 / # type: ignore | ||
| 0 | ||
| ) | ||
|
|
||
| y = ( | ||
| 4 / | ||
| 0 # type: ignore | ||
| ) | ||
| ``` | ||
|
|
||
| Pyright diverges from this behavior and instead applies a suppression if its range intersects with | ||
| the diagnostic range. This can be problematic for nested expressions because a suppression in a | ||
| child expression now suppresses errors in the outer expression. | ||
|
|
||
| For example, the `type: ignore` comment in this example suppresses the error of adding `2` to | ||
| `"test"` and adding `"other"` to the result of the cast. | ||
|
|
||
| ```py path=nested.py | ||
| # fmt: off | ||
| from typing import cast | ||
|
|
||
| y = ( | ||
| cast(int, "test" + | ||
| 2 # type: ignore | ||
| ) | ||
| + "other" # TODO: expected-error[invalid-operator] | ||
| ) | ||
| ``` | ||
|
|
||
| Mypy flags the second usage. | ||
|
|
||
| ## Before opening parenthesis | ||
|
|
||
| A suppression that applies to all errors before the opening parenthesis. | ||
|
|
||
| ```py | ||
| a: Test = ( # type: ignore | ||
| Test() # error: [unresolved-reference] | ||
| ) # fmt: skip | ||
| ``` | ||
|
|
||
| ## Multiline string | ||
|
|
||
| ```py | ||
| a: int = 4 | ||
| a = """ | ||
| This is a multiline string and the suppression is at its end | ||
| """ # type: ignore | ||
| ``` | ||
|
|
||
| ## Line continuations | ||
|
|
||
| Suppressions after a line continuation apply to all previous lines. | ||
|
|
||
| ```py | ||
| # fmt: off | ||
| a = test \ | ||
| + 2 # type: ignore | ||
|
|
||
| a = test \ | ||
| + a \ | ||
| + 2 # type: ignore | ||
| ``` | ||
|
|
||
| ## Codes | ||
|
|
||
| Mypy supports `type: ignore[code]`. Red Knot doesn't understand mypy's rule names. Therefore, ignore | ||
| the codes and suppress all errors. | ||
|
|
||
| ```py | ||
| a = test # type: ignore[name-defined] | ||
| ``` | ||
|
|
||
| ## Nested comments | ||
|
|
||
| TODO: We should support this for better interopability with other suppression comments. | ||
|
|
||
| ```py | ||
| # fmt: off | ||
| # TODO this error should be suppressed | ||
| # error: [unresolved-reference] | ||
| a = test \ | ||
| + 2 # fmt: skip # type: ignore | ||
|
|
||
| a = test \ | ||
| + 2 # type: ignore # fmt: skip | ||
| ``` | ||
|
|
||
| ## Misspelled `type: ignore` | ||
|
|
||
| ```py | ||
| # error: [unresolved-reference] | ||
| a = test + 2 # type: ignoree | ||
| ``` | ||
|
|
||
| ## Invalid - ignore on opening parentheses | ||
|
|
||
| `type: ignore` comments after an opening parentheses suppress any type errors inside the parentheses | ||
| in Pyright. Neither Ruff, nor mypy support this and neither does Red Knot. | ||
|
|
||
| ```py | ||
| # fmt: off | ||
| a = ( # type: ignore | ||
| test + 4 # error: [unresolved-reference] | ||
| ) | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,50 +1,104 @@ | ||
| use salsa; | ||
| use ruff_python_parser::TokenKind; | ||
| use ruff_source_file::LineRanges; | ||
| use ruff_text_size::{Ranged, TextRange}; | ||
|
|
||
| use ruff_db::{files::File, parsed::comment_ranges, source::source_text}; | ||
| use ruff_index::{newtype_index, IndexVec}; | ||
| use ruff_db::{files::File, parsed::parsed_module, source::source_text}; | ||
|
|
||
| use crate::{lint::LintId, Db}; | ||
|
|
||
| #[salsa::tracked(return_ref)] | ||
| pub(crate) fn suppressions(db: &dyn Db, file: File) -> IndexVec<SuppressionIndex, Suppression> { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Whoops. I accidentally commited this file when I rebased https://github.com/astral-sh/ruff/pull/14956/files#diff-b1807b646317ac1945d748f7db40451ac62c582b1bbc049b174e8d98f13d3f22 Probably because it didn't get stashed with |
||
| let comments = comment_ranges(db.upcast(), file); | ||
| pub(crate) fn suppressions(db: &dyn Db, file: File) -> Suppressions { | ||
| let source = source_text(db.upcast(), file); | ||
| let parsed = parsed_module(db.upcast(), file); | ||
|
|
||
| let mut suppressions = IndexVec::default(); | ||
|
|
||
| for range in comments { | ||
| let text = &source[range]; | ||
|
|
||
| if text.starts_with("# type: ignore") { | ||
| suppressions.push(Suppression { | ||
| target: None, | ||
| kind: SuppressionKind::TypeIgnore, | ||
| }); | ||
| } else if text.starts_with("# knot: ignore") { | ||
| suppressions.push(Suppression { | ||
| target: None, | ||
| kind: SuppressionKind::KnotIgnore, | ||
| }); | ||
| // TODO: Support `type: ignore` comments at the | ||
| // [start of the file](https://typing.readthedocs.io/en/latest/spec/directives.html#type-ignore-comments). | ||
| let mut suppressions = Vec::default(); | ||
| let mut line_start = source.bom_start_offset(); | ||
|
|
||
| for token in parsed.tokens() { | ||
| match token.kind() { | ||
| TokenKind::Comment => { | ||
| let text = &source[token.range()]; | ||
|
|
||
| let suppressed_range = TextRange::new(line_start, token.end()); | ||
|
|
||
| if text.strip_prefix("# type: ignore").is_some_and(|suffix| { | ||
| suffix.is_empty() | ||
| || suffix.starts_with(char::is_whitespace) | ||
| || suffix.starts_with('[') | ||
| }) { | ||
| suppressions.push(Suppression { suppressed_range }); | ||
| } | ||
| } | ||
| TokenKind::Newline | TokenKind::NonLogicalNewline => { | ||
| line_start = token.end(); | ||
| } | ||
| _ => {} | ||
| } | ||
| } | ||
|
|
||
| suppressions | ||
| Suppressions { suppressions } | ||
| } | ||
|
|
||
| #[newtype_index] | ||
| pub(crate) struct SuppressionIndex; | ||
|
|
||
| #[derive(Clone, Debug, Eq, PartialEq, Hash)] | ||
| pub(crate) struct Suppression { | ||
| target: Option<LintId>, | ||
| kind: SuppressionKind, | ||
| /// The suppression comments of a single file. | ||
| #[derive(Clone, Debug, Eq, PartialEq)] | ||
| pub(crate) struct Suppressions { | ||
| /// The suppressions sorted by the suppressed range. | ||
| suppressions: Vec<Suppression>, | ||
| } | ||
|
|
||
| #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] | ||
| pub(crate) enum SuppressionKind { | ||
| /// A `type: ignore` comment | ||
| TypeIgnore, | ||
| impl Suppressions { | ||
| /// Finds a suppression for the specified lint. | ||
| /// | ||
| /// Returns the first matching suppression if more than one suppression apply to `range` and `id`. | ||
| /// | ||
| /// Returns `None` if the lint isn't suppressed. | ||
| pub(crate) fn find_suppression(&self, range: TextRange, _id: LintId) -> Option<&Suppression> { | ||
| // TODO(micha): | ||
| // * Test if the suppression suppresses the passed lint | ||
| self.for_range(range).next() | ||
| } | ||
|
|
||
| /// A `knot: ignore` comment | ||
| KnotIgnore, | ||
| /// Returns all suppression comments that apply for `range`. | ||
| /// | ||
| /// A suppression applies for the given range if it contains the range's | ||
| /// start or end offset. This means the suppression is on the same line | ||
| /// as the diagnostic's start or end. | ||
| fn for_range(&self, range: TextRange) -> impl Iterator<Item = &Suppression> + '_ { | ||
| // First find the index of the suppression comment that ends right before the range | ||
| // starts. This allows us to skip suppressions that are not relevant for the range. | ||
| let end_offset = self | ||
| .suppressions | ||
| .binary_search_by_key(&range.start(), |suppression| { | ||
| suppression.suppressed_range.end() | ||
| }) | ||
| .unwrap_or_else(|index| index); | ||
|
|
||
| // From here, search the remaining suppression comments for one that | ||
| // contains the range's start or end offset. Stop the search | ||
| // as soon as the suppression's range and the range no longer overlap. | ||
| self.suppressions[end_offset..] | ||
| .iter() | ||
| // Stop searching if the suppression starts after the range we're looking for. | ||
| .take_while(move |suppression| range.end() >= suppression.suppressed_range.start()) | ||
| .filter(move |suppression| { | ||
| // Don't use intersect to avoid that suppressions on inner-expression | ||
| // ignore errors for outer expressions | ||
| suppression.suppressed_range.contains(range.start()) | ||
| || suppression.suppressed_range.contains(range.end()) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| /// A `type: ignore` or `knot: ignore` suppression comment. | ||
| #[derive(Clone, Debug, Eq, PartialEq, Hash)] | ||
| pub(crate) struct Suppression { | ||
| /// The range for which this suppression applies. | ||
| /// Most of the time, this is the range of the comment's line. | ||
| /// However, there are few cases where the range gets expanded to | ||
| /// cover multiple lines: | ||
| /// * multiline strings: `expr + """multiline\nstring""" # type: ignore` | ||
| /// * line continuations: `expr \ + "test" # type: ignore` | ||
| suppressed_range: TextRange, | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we use
/ 0here and then the test would function without a TODO?Or maybe that muddies the comparison with pyright, which doesn't error on division by zero
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't work with
/ 0because Red Knot doesn't supportcastyet (and without the cast it'sUnknown / 0which we seem to accept)