Skip to content

Commit 0293908

Browse files
authored
Implement RUF028 to detect useless formatter suppression comments (#9899)
<!-- Thank you for contributing to Ruff! To help us out with reviewing, please consider the following: - Does this pull request include a summary of the change? (See below.) - Does this pull request include a descriptive title? - Does this pull request include references to any relevant issues? --> Fixes #6611 ## Summary This lint rule spots comments that are _intended_ to suppress or enable the formatter, but will be ignored by the Ruff formatter. We borrow some functions the formatter uses for determining comment placement / putting them in context within an AST. The analysis function uses an AST visitor to visit each comment and attach it to the AST. It then uses that context to check: 1. Is this comment in an expression? 2. Does this comment have bad placement? (e.g. a `# fmt: skip` above a function instead of at the end of a line) 3. Is this comment redundant? 4. Does this comment actually suppress any code? 5. Does this comment have ambiguous placement? (e.g. a `# fmt: off` above an `else:` block) If any of these are true, a violation is thrown. The reported reason depends on the order of the above check-list: in other words, a `# fmt: skip` comment on its own line within a list expression will be reported as being in an expression, since that reason takes priority. The lint suggests removing the comment as an unsafe fix, regardless of the reason. ## Test Plan A snapshot test has been created.
1 parent 36bc725 commit 0293908

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1215
-438
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
def fmt_off_between_lists():
2+
test_list = [
3+
# fmt: off
4+
1,
5+
2,
6+
3,
7+
]
8+
9+
10+
# note: the second `fmt: skip`` should be OK
11+
def fmt_skip_on_own_line():
12+
# fmt: skip
13+
pass # fmt: skip
14+
15+
16+
@fmt_skip_on_own_line
17+
# fmt: off
18+
@fmt_off_between_lists
19+
def fmt_off_between_decorators():
20+
pass
21+
22+
23+
@fmt_off_between_decorators
24+
# fmt: off
25+
class FmtOffBetweenClassDecorators:
26+
...
27+
28+
29+
def fmt_off_in_else():
30+
x = [1, 2, 3]
31+
for val in x:
32+
print(x)
33+
# fmt: off
34+
else:
35+
print("done")
36+
while False:
37+
print("while")
38+
# fmt: off
39+
# fmt: off
40+
else:
41+
print("done")
42+
if len(x) > 3:
43+
print("huh?")
44+
# fmt: on
45+
# fmt: off
46+
else:
47+
print("expected")
48+
49+
50+
class Test:
51+
@classmethod
52+
# fmt: off
53+
def cls_method_a(
54+
# fmt: off
55+
cls,
56+
) -> None: # noqa: test # fmt: skip
57+
pass
58+
59+
60+
def fmt_on_trailing():
61+
# fmt: off
62+
val = 5 # fmt: on
63+
pass # fmt: on

crates/ruff_linter/src/checkers/ast/analyze/module.rs

+4-1
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ use ruff_python_ast::Suite;
22

33
use crate::checkers::ast::Checker;
44
use crate::codes::Rule;
5-
use crate::rules::flake8_bugbear;
5+
use crate::rules::{flake8_bugbear, ruff};
66

77
/// Run lint rules over a module.
88
pub(crate) fn module(suite: &Suite, checker: &mut Checker) {
99
if checker.enabled(Rule::FStringDocstring) {
1010
flake8_bugbear::rules::f_string_docstring(checker, suite);
1111
}
12+
if checker.enabled(Rule::InvalidFormatterSuppressionComment) {
13+
ruff::rules::ignored_formatter_suppression_comment(checker, suite);
14+
}
1215
}

crates/ruff_linter/src/checkers/noqa.rs

+9-50
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
use std::path::Path;
44

55
use itertools::Itertools;
6-
use ruff_text_size::{Ranged, TextLen, TextRange};
6+
use ruff_text_size::Ranged;
77

88
use ruff_diagnostics::{Diagnostic, Edit, Fix};
9-
use ruff_python_trivia::{CommentRanges, PythonWhitespace};
9+
use ruff_python_trivia::CommentRanges;
1010
use ruff_source_file::Locator;
1111

12+
use crate::fix::edits::delete_comment;
1213
use crate::noqa;
1314
use crate::noqa::{Directive, FileExemption, NoqaDirectives, NoqaMapping};
1415
use crate::registry::{AsRule, Rule, RuleSet};
@@ -111,7 +112,8 @@ pub(crate) fn check_noqa(
111112
if line.matches.is_empty() {
112113
let mut diagnostic =
113114
Diagnostic::new(UnusedNOQA { codes: None }, directive.range());
114-
diagnostic.set_fix(Fix::safe_edit(delete_noqa(directive.range(), locator)));
115+
diagnostic
116+
.set_fix(Fix::safe_edit(delete_comment(directive.range(), locator)));
115117

116118
diagnostics.push(diagnostic);
117119
}
@@ -177,8 +179,10 @@ pub(crate) fn check_noqa(
177179
directive.range(),
178180
);
179181
if valid_codes.is_empty() {
180-
diagnostic
181-
.set_fix(Fix::safe_edit(delete_noqa(directive.range(), locator)));
182+
diagnostic.set_fix(Fix::safe_edit(delete_comment(
183+
directive.range(),
184+
locator,
185+
)));
182186
} else {
183187
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement(
184188
format!("# noqa: {}", valid_codes.join(", ")),
@@ -195,48 +199,3 @@ pub(crate) fn check_noqa(
195199
ignored_diagnostics.sort_unstable();
196200
ignored_diagnostics
197201
}
198-
199-
/// Generate a [`Edit`] to delete a `noqa` directive.
200-
fn delete_noqa(range: TextRange, locator: &Locator) -> Edit {
201-
let line_range = locator.line_range(range.start());
202-
203-
// Compute the leading space.
204-
let prefix = locator.slice(TextRange::new(line_range.start(), range.start()));
205-
let leading_space_len = prefix.text_len() - prefix.trim_whitespace_end().text_len();
206-
207-
// Compute the trailing space.
208-
let suffix = locator.slice(TextRange::new(range.end(), line_range.end()));
209-
let trailing_space_len = suffix.text_len() - suffix.trim_whitespace_start().text_len();
210-
211-
// Ex) `# noqa`
212-
if line_range
213-
== TextRange::new(
214-
range.start() - leading_space_len,
215-
range.end() + trailing_space_len,
216-
)
217-
{
218-
let full_line_end = locator.full_line_end(line_range.end());
219-
Edit::deletion(line_range.start(), full_line_end)
220-
}
221-
// Ex) `x = 1 # noqa`
222-
else if range.end() + trailing_space_len == line_range.end() {
223-
Edit::deletion(range.start() - leading_space_len, line_range.end())
224-
}
225-
// Ex) `x = 1 # noqa # type: ignore`
226-
else if locator
227-
.slice(TextRange::new(
228-
range.end() + trailing_space_len,
229-
line_range.end(),
230-
))
231-
.starts_with('#')
232-
{
233-
Edit::deletion(range.start(), range.end() + trailing_space_len)
234-
}
235-
// Ex) `x = 1 # noqa here`
236-
else {
237-
Edit::deletion(
238-
range.start() + "# ".text_len(),
239-
range.end() + trailing_space_len,
240-
)
241-
}
242-
}

crates/ruff_linter/src/codes.rs

+1
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
945945
(Ruff, "025") => (RuleGroup::Preview, rules::ruff::rules::UnnecessaryDictComprehensionForIterable),
946946
(Ruff, "026") => (RuleGroup::Preview, rules::ruff::rules::DefaultFactoryKwarg),
947947
(Ruff, "027") => (RuleGroup::Preview, rules::ruff::rules::MissingFStringSyntax),
948+
(Ruff, "028") => (RuleGroup::Preview, rules::ruff::rules::InvalidFormatterSuppressionComment),
948949
(Ruff, "100") => (RuleGroup::Stable, rules::ruff::rules::UnusedNOQA),
949950
(Ruff, "200") => (RuleGroup::Stable, rules::ruff::rules::InvalidPyprojectToml),
950951
#[cfg(feature = "test-rules")]

crates/ruff_linter/src/fix/edits.rs

+45
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,51 @@ pub(crate) fn delete_stmt(
6262
}
6363
}
6464

65+
/// Generate a [`Edit`] to delete a comment (for example: a `noqa` directive).
66+
pub(crate) fn delete_comment(range: TextRange, locator: &Locator) -> Edit {
67+
let line_range = locator.line_range(range.start());
68+
69+
// Compute the leading space.
70+
let prefix = locator.slice(TextRange::new(line_range.start(), range.start()));
71+
let leading_space_len = prefix.text_len() - prefix.trim_whitespace_end().text_len();
72+
73+
// Compute the trailing space.
74+
let suffix = locator.slice(TextRange::new(range.end(), line_range.end()));
75+
let trailing_space_len = suffix.text_len() - suffix.trim_whitespace_start().text_len();
76+
77+
// Ex) `# noqa`
78+
if line_range
79+
== TextRange::new(
80+
range.start() - leading_space_len,
81+
range.end() + trailing_space_len,
82+
)
83+
{
84+
let full_line_end = locator.full_line_end(line_range.end());
85+
Edit::deletion(line_range.start(), full_line_end)
86+
}
87+
// Ex) `x = 1 # noqa`
88+
else if range.end() + trailing_space_len == line_range.end() {
89+
Edit::deletion(range.start() - leading_space_len, line_range.end())
90+
}
91+
// Ex) `x = 1 # noqa # type: ignore`
92+
else if locator
93+
.slice(TextRange::new(
94+
range.end() + trailing_space_len,
95+
line_range.end(),
96+
))
97+
.starts_with('#')
98+
{
99+
Edit::deletion(range.start(), range.end() + trailing_space_len)
100+
}
101+
// Ex) `x = 1 # noqa here`
102+
else {
103+
Edit::deletion(
104+
range.start() + "# ".text_len(),
105+
range.end() + trailing_space_len,
106+
)
107+
}
108+
}
109+
65110
/// Generate a `Fix` to remove the specified imports from an `import` statement.
66111
pub(crate) fn remove_unused_imports<'a>(
67112
member_names: impl Iterator<Item = &'a str>,

crates/ruff_linter/src/rules/ruff/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ mod tests {
4949
#[test_case(Rule::MissingFStringSyntax, Path::new("RUF027_0.py"))]
5050
#[test_case(Rule::MissingFStringSyntax, Path::new("RUF027_1.py"))]
5151
#[test_case(Rule::MissingFStringSyntax, Path::new("RUF027_2.py"))]
52+
#[test_case(Rule::InvalidFormatterSuppressionComment, Path::new("RUF028.py"))]
5253
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
5354
let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy());
5455
let diagnostics = test_path(

0 commit comments

Comments
 (0)