Skip to content

Commit 913bce3

Browse files
MichaReisercarljmAlexWaygood
authored
Basic support for type: ignore comments (#15046)
## Summary This PR adds initial support for `type: ignore`. It doesn't do anything fancy yet like: * Detecting invalid type ignore comments * Detecting type ignore comments that are part of another suppression comment: `# fmt: skip # type: ignore` * Suppressing specific lints `type: ignore [code]` * Detecting unsused type ignore comments * ... The goal is to add this functionality in separate PRs. ## Test Plan --------- Co-authored-by: Carl Meyer <carl@astral.sh> Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
1 parent 6195c02 commit 913bce3

5 files changed

Lines changed: 234 additions & 37 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Suppressing errors with `type: ignore`
2+
3+
Type check errors can be suppressed by a `type: ignore` comment on the same line as the violation.
4+
5+
## Simple `type: ignore`
6+
7+
```py
8+
a = 4 + test # type: ignore
9+
```
10+
11+
## Multiline ranges
12+
13+
A diagnostic with a multiline range can be suppressed by a comment on the same line as the
14+
diagnostic's start or end. This is the same behavior as Mypy's.
15+
16+
```py
17+
# fmt: off
18+
y = (
19+
4 / 0 # type: ignore
20+
)
21+
22+
y = (
23+
4 / # type: ignore
24+
0
25+
)
26+
27+
y = (
28+
4 /
29+
0 # type: ignore
30+
)
31+
```
32+
33+
Pyright diverges from this behavior and instead applies a suppression if its range intersects with
34+
the diagnostic range. This can be problematic for nested expressions because a suppression in a
35+
child expression now suppresses errors in the outer expression.
36+
37+
For example, the `type: ignore` comment in this example suppresses the error of adding `2` to
38+
`"test"` and adding `"other"` to the result of the cast.
39+
40+
```py path=nested.py
41+
# fmt: off
42+
from typing import cast
43+
44+
y = (
45+
cast(int, "test" +
46+
2 # type: ignore
47+
)
48+
+ "other" # TODO: expected-error[invalid-operator]
49+
)
50+
```
51+
52+
Mypy flags the second usage.
53+
54+
## Before opening parenthesis
55+
56+
A suppression that applies to all errors before the opening parenthesis.
57+
58+
```py
59+
a: Test = ( # type: ignore
60+
Test() # error: [unresolved-reference]
61+
) # fmt: skip
62+
```
63+
64+
## Multiline string
65+
66+
```py
67+
a: int = 4
68+
a = """
69+
This is a multiline string and the suppression is at its end
70+
""" # type: ignore
71+
```
72+
73+
## Line continuations
74+
75+
Suppressions after a line continuation apply to all previous lines.
76+
77+
```py
78+
# fmt: off
79+
a = test \
80+
+ 2 # type: ignore
81+
82+
a = test \
83+
+ a \
84+
+ 2 # type: ignore
85+
```
86+
87+
## Codes
88+
89+
Mypy supports `type: ignore[code]`. Red Knot doesn't understand mypy's rule names. Therefore, ignore
90+
the codes and suppress all errors.
91+
92+
```py
93+
a = test # type: ignore[name-defined]
94+
```
95+
96+
## Nested comments
97+
98+
TODO: We should support this for better interopability with other suppression comments.
99+
100+
```py
101+
# fmt: off
102+
# TODO this error should be suppressed
103+
# error: [unresolved-reference]
104+
a = test \
105+
+ 2 # fmt: skip # type: ignore
106+
107+
a = test \
108+
+ 2 # type: ignore # fmt: skip
109+
```
110+
111+
## Misspelled `type: ignore`
112+
113+
```py
114+
# error: [unresolved-reference]
115+
a = test + 2 # type: ignoree
116+
```
117+
118+
## Invalid - ignore on opening parentheses
119+
120+
`type: ignore` comments after an opening parentheses suppress any type errors inside the parentheses
121+
in Pyright. Neither Ruff, nor mypy support this and neither does Red Knot.
122+
123+
```py
124+
# fmt: off
125+
a = ( # type: ignore
126+
test + 4 # error: [unresolved-reference]
127+
)
128+
```

crates/red_knot_python_semantic/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub mod semantic_index;
2222
mod semantic_model;
2323
pub(crate) mod site_packages;
2424
mod stdlib;
25+
mod suppression;
2526
pub(crate) mod symbol;
2627
pub mod types;
2728
mod unpack;
Lines changed: 88 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,104 @@
1-
use salsa;
1+
use ruff_python_parser::TokenKind;
2+
use ruff_source_file::LineRanges;
3+
use ruff_text_size::{Ranged, TextRange};
24

3-
use ruff_db::{files::File, parsed::comment_ranges, source::source_text};
4-
use ruff_index::{newtype_index, IndexVec};
5+
use ruff_db::{files::File, parsed::parsed_module, source::source_text};
56

67
use crate::{lint::LintId, Db};
78

89
#[salsa::tracked(return_ref)]
9-
pub(crate) fn suppressions(db: &dyn Db, file: File) -> IndexVec<SuppressionIndex, Suppression> {
10-
let comments = comment_ranges(db.upcast(), file);
10+
pub(crate) fn suppressions(db: &dyn Db, file: File) -> Suppressions {
1111
let source = source_text(db.upcast(), file);
12+
let parsed = parsed_module(db.upcast(), file);
1213

13-
let mut suppressions = IndexVec::default();
14-
15-
for range in comments {
16-
let text = &source[range];
17-
18-
if text.starts_with("# type: ignore") {
19-
suppressions.push(Suppression {
20-
target: None,
21-
kind: SuppressionKind::TypeIgnore,
22-
});
23-
} else if text.starts_with("# knot: ignore") {
24-
suppressions.push(Suppression {
25-
target: None,
26-
kind: SuppressionKind::KnotIgnore,
27-
});
14+
// TODO: Support `type: ignore` comments at the
15+
// [start of the file](https://typing.readthedocs.io/en/latest/spec/directives.html#type-ignore-comments).
16+
let mut suppressions = Vec::default();
17+
let mut line_start = source.bom_start_offset();
18+
19+
for token in parsed.tokens() {
20+
match token.kind() {
21+
TokenKind::Comment => {
22+
let text = &source[token.range()];
23+
24+
let suppressed_range = TextRange::new(line_start, token.end());
25+
26+
if text.strip_prefix("# type: ignore").is_some_and(|suffix| {
27+
suffix.is_empty()
28+
|| suffix.starts_with(char::is_whitespace)
29+
|| suffix.starts_with('[')
30+
}) {
31+
suppressions.push(Suppression { suppressed_range });
32+
}
33+
}
34+
TokenKind::Newline | TokenKind::NonLogicalNewline => {
35+
line_start = token.end();
36+
}
37+
_ => {}
2838
}
2939
}
3040

31-
suppressions
41+
Suppressions { suppressions }
3242
}
3343

34-
#[newtype_index]
35-
pub(crate) struct SuppressionIndex;
36-
37-
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
38-
pub(crate) struct Suppression {
39-
target: Option<LintId>,
40-
kind: SuppressionKind,
44+
/// The suppression comments of a single file.
45+
#[derive(Clone, Debug, Eq, PartialEq)]
46+
pub(crate) struct Suppressions {
47+
/// The suppressions sorted by the suppressed range.
48+
suppressions: Vec<Suppression>,
4149
}
4250

43-
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
44-
pub(crate) enum SuppressionKind {
45-
/// A `type: ignore` comment
46-
TypeIgnore,
51+
impl Suppressions {
52+
/// Finds a suppression for the specified lint.
53+
///
54+
/// Returns the first matching suppression if more than one suppression apply to `range` and `id`.
55+
///
56+
/// Returns `None` if the lint isn't suppressed.
57+
pub(crate) fn find_suppression(&self, range: TextRange, _id: LintId) -> Option<&Suppression> {
58+
// TODO(micha):
59+
// * Test if the suppression suppresses the passed lint
60+
self.for_range(range).next()
61+
}
4762

48-
/// A `knot: ignore` comment
49-
KnotIgnore,
63+
/// Returns all suppression comments that apply for `range`.
64+
///
65+
/// A suppression applies for the given range if it contains the range's
66+
/// start or end offset. This means the suppression is on the same line
67+
/// as the diagnostic's start or end.
68+
fn for_range(&self, range: TextRange) -> impl Iterator<Item = &Suppression> + '_ {
69+
// First find the index of the suppression comment that ends right before the range
70+
// starts. This allows us to skip suppressions that are not relevant for the range.
71+
let end_offset = self
72+
.suppressions
73+
.binary_search_by_key(&range.start(), |suppression| {
74+
suppression.suppressed_range.end()
75+
})
76+
.unwrap_or_else(|index| index);
77+
78+
// From here, search the remaining suppression comments for one that
79+
// contains the range's start or end offset. Stop the search
80+
// as soon as the suppression's range and the range no longer overlap.
81+
self.suppressions[end_offset..]
82+
.iter()
83+
// Stop searching if the suppression starts after the range we're looking for.
84+
.take_while(move |suppression| range.end() >= suppression.suppressed_range.start())
85+
.filter(move |suppression| {
86+
// Don't use intersect to avoid that suppressions on inner-expression
87+
// ignore errors for outer expressions
88+
suppression.suppressed_range.contains(range.start())
89+
|| suppression.suppressed_range.contains(range.end())
90+
})
91+
}
92+
}
93+
94+
/// A `type: ignore` or `knot: ignore` suppression comment.
95+
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
96+
pub(crate) struct Suppression {
97+
/// The range for which this suppression applies.
98+
/// Most of the time, this is the range of the comment's line.
99+
/// However, there are few cases where the range gets expanded to
100+
/// cover multiple lines:
101+
/// * multiline strings: `expr + """multiline\nstring""" # type: ignore`
102+
/// * line continuations: `expr \ + "test" # type: ignore`
103+
suppressed_range: TextRange,
50104
}

crates/red_knot_python_semantic/src/types/context.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use ruff_text_size::Ranged;
1010

1111
use crate::{
1212
lint::{LintId, LintMetadata},
13+
suppression::suppressions,
1314
Db,
1415
};
1516

@@ -74,6 +75,15 @@ impl<'db> InferContext<'db> {
7475
return;
7576
};
7677

78+
let suppressions = suppressions(self.db, self.file);
79+
80+
if suppressions
81+
.find_suppression(node.range(), LintId::of(lint))
82+
.is_some()
83+
{
84+
return;
85+
}
86+
7787
self.report_diagnostic(node, DiagnosticId::Lint(lint.name()), severity, message);
7888
}
7989

crates/red_knot_test/src/lib.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,11 @@ fn run_test(db: &mut db::Db, test: &parser::MarkdownTest) -> Result<(), Failures
9797

9898
let test_files: Vec<_> = test
9999
.files()
100-
.map(|embedded| {
100+
.filter_map(|embedded| {
101+
if embedded.lang == "ignore" {
102+
return None;
103+
}
104+
101105
assert!(
102106
matches!(embedded.lang, "py" | "pyi"),
103107
"Non-Python files not supported yet."
@@ -106,10 +110,10 @@ fn run_test(db: &mut db::Db, test: &parser::MarkdownTest) -> Result<(), Failures
106110
db.write_file(&full_path, embedded.code).unwrap();
107111
let file = system_path_to_file(db, full_path).unwrap();
108112

109-
TestFile {
113+
Some(TestFile {
110114
file,
111115
backtick_offset: embedded.md_offset,
112-
}
116+
})
113117
})
114118
.collect();
115119

0 commit comments

Comments
 (0)