Skip to content

Commit 3d334a3

Browse files
authored
Report diagnostics for invalid/unmatched range suppression comments (#21908)
## Summary - Adds new RUF103 and RUF104 diagnostics for invalid and unmatched suppression comments - Reports RUF100 for any unused range suppression - Reports RUF102 for range suppression comment with invalid rule codes - Reports RUF103 for range suppression comment with invalid suppression syntax - Reports RUF104 diagnostics for any unmatched range suppression comment (disable w/o enable) ## Test Plan Updated snapshots from test cases with unmatched suppression comments Issue #3711 Fixes #21878 Fixes #21875
1 parent 2e44a86 commit 3d334a3

File tree

12 files changed

+542
-83
lines changed

12 files changed

+542
-83
lines changed

crates/ruff_linter/resources/test/fixtures/ruff/suppressions.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,26 @@ def f():
8686
# Multiple codes but none are used
8787
# ruff: disable[E741, F401, F841]
8888
print("hello")
89+
90+
91+
def f():
92+
# Unknown rule codes
93+
# ruff: disable[YF829]
94+
# ruff: disable[F841, RQW320]
95+
value = 0
96+
# ruff: enable[F841, RQW320]
97+
# ruff: enable[YF829]
98+
99+
100+
def f():
101+
# External rule codes should be ignored
102+
# ruff: disable[TK421]
103+
print("hello")
104+
# ruff: enable[TK421]
105+
106+
107+
def f():
108+
# Empty or missing rule codes
109+
# ruff: disable
110+
# ruff: disable[]
111+
print("hello")

crates/ruff_linter/src/codes.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,6 +1064,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
10641064
(Ruff, "100") => rules::ruff::rules::UnusedNOQA,
10651065
(Ruff, "101") => rules::ruff::rules::RedirectedNOQA,
10661066
(Ruff, "102") => rules::ruff::rules::InvalidRuleCode,
1067+
(Ruff, "103") => rules::ruff::rules::InvalidSuppressionComment,
1068+
(Ruff, "104") => rules::ruff::rules::UnmatchedSuppressionComment,
10671069

10681070
(Ruff, "200") => rules::ruff::rules::InvalidPyprojectToml,
10691071
#[cfg(any(feature = "test-rules", test))]

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,12 +313,20 @@ mod tests {
313313
Rule::UnusedVariable,
314314
Rule::AmbiguousVariableName,
315315
Rule::UnusedNOQA,
316-
]),
316+
Rule::InvalidRuleCode,
317+
Rule::InvalidSuppressionComment,
318+
Rule::UnmatchedSuppressionComment,
319+
])
320+
.with_external_rules(&["TK421"]),
317321
&settings::LinterSettings::for_rules(vec![
318322
Rule::UnusedVariable,
319323
Rule::AmbiguousVariableName,
320324
Rule::UnusedNOQA,
325+
Rule::InvalidRuleCode,
326+
Rule::InvalidSuppressionComment,
327+
Rule::UnmatchedSuppressionComment,
321328
])
329+
.with_external_rules(&["TK421"])
322330
.with_preview_mode(),
323331
);
324332
Ok(())

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

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@ use crate::registry::Rule;
99
use crate::rule_redirects::get_redirect_target;
1010
use crate::{AlwaysFixableViolation, Edit, Fix};
1111

12+
#[derive(Debug, PartialEq, Eq)]
13+
pub(crate) enum InvalidRuleCodeKind {
14+
Noqa,
15+
Suppression,
16+
}
17+
18+
impl InvalidRuleCodeKind {
19+
fn as_str(&self) -> &str {
20+
match self {
21+
InvalidRuleCodeKind::Noqa => "`# noqa`",
22+
InvalidRuleCodeKind::Suppression => "suppression",
23+
}
24+
}
25+
}
26+
1227
/// ## What it does
1328
/// Checks for `noqa` codes that are invalid.
1429
///
@@ -36,12 +51,17 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
3651
#[violation_metadata(preview_since = "0.11.4")]
3752
pub(crate) struct InvalidRuleCode {
3853
pub(crate) rule_code: String,
54+
pub(crate) kind: InvalidRuleCodeKind,
3955
}
4056

4157
impl AlwaysFixableViolation for InvalidRuleCode {
4258
#[derive_message_formats]
4359
fn message(&self) -> String {
44-
format!("Invalid rule code in `# noqa`: {}", self.rule_code)
60+
format!(
61+
"Invalid rule code in {}: {}",
62+
self.kind.as_str(),
63+
self.rule_code
64+
)
4565
}
4666

4767
fn fix_title(&self) -> String {
@@ -61,15 +81,17 @@ pub(crate) fn invalid_noqa_code(
6181
continue;
6282
};
6383

64-
let all_valid = directive.iter().all(|code| code_is_valid(code, external));
84+
let all_valid = directive
85+
.iter()
86+
.all(|code| code_is_valid(code.as_str(), external));
6587

6688
if all_valid {
6789
continue;
6890
}
6991

7092
let (valid_codes, invalid_codes): (Vec<_>, Vec<_>) = directive
7193
.iter()
72-
.partition(|&code| code_is_valid(code, external));
94+
.partition(|&code| code_is_valid(code.as_str(), external));
7395

7496
if valid_codes.is_empty() {
7597
all_codes_invalid_diagnostic(directive, invalid_codes, context);
@@ -81,10 +103,9 @@ pub(crate) fn invalid_noqa_code(
81103
}
82104
}
83105

84-
fn code_is_valid(code: &Code, external: &[String]) -> bool {
85-
let code_str = code.as_str();
86-
Rule::from_code(get_redirect_target(code_str).unwrap_or(code_str)).is_ok()
87-
|| external.iter().any(|ext| code_str.starts_with(ext))
106+
pub(crate) fn code_is_valid(code: &str, external: &[String]) -> bool {
107+
Rule::from_code(get_redirect_target(code).unwrap_or(code)).is_ok()
108+
|| external.iter().any(|ext| code.starts_with(ext))
88109
}
89110

90111
fn all_codes_invalid_diagnostic(
@@ -100,6 +121,7 @@ fn all_codes_invalid_diagnostic(
100121
.map(Code::as_str)
101122
.collect::<Vec<_>>()
102123
.join(", "),
124+
kind: InvalidRuleCodeKind::Noqa,
103125
},
104126
directive.range(),
105127
)
@@ -116,6 +138,7 @@ fn some_codes_are_invalid_diagnostic(
116138
.report_diagnostic(
117139
InvalidRuleCode {
118140
rule_code: invalid_code.to_string(),
141+
kind: InvalidRuleCodeKind::Noqa,
119142
},
120143
invalid_code.range(),
121144
)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
use ruff_macros::{ViolationMetadata, derive_message_formats};
2+
3+
use crate::AlwaysFixableViolation;
4+
use crate::suppression::{InvalidSuppressionKind, ParseErrorKind};
5+
6+
/// ## What it does
7+
/// Checks for invalid suppression comments
8+
///
9+
/// ## Why is this bad?
10+
/// Invalid suppression comments are ignored by Ruff, and should either
11+
/// be fixed or removed to avoid confusion.
12+
///
13+
/// ## Example
14+
/// ```python
15+
/// ruff: disable # missing codes
16+
/// ```
17+
///
18+
/// Use instead:
19+
/// ```python
20+
/// # ruff: disable[E501]
21+
/// ```
22+
///
23+
/// Or delete the invalid suppression comment.
24+
///
25+
/// ## References
26+
/// - [Ruff error suppression](https://docs.astral.sh/ruff/linter/#error-suppression)
27+
#[derive(ViolationMetadata)]
28+
#[violation_metadata(preview_since = "0.14.9")]
29+
pub(crate) struct InvalidSuppressionComment {
30+
pub(crate) kind: InvalidSuppressionCommentKind,
31+
}
32+
33+
impl AlwaysFixableViolation for InvalidSuppressionComment {
34+
#[derive_message_formats]
35+
fn message(&self) -> String {
36+
let msg = match self.kind {
37+
InvalidSuppressionCommentKind::Invalid(InvalidSuppressionKind::Indentation) => {
38+
"unexpected indentation".to_string()
39+
}
40+
InvalidSuppressionCommentKind::Invalid(InvalidSuppressionKind::Trailing) => {
41+
"trailing comments are not supported".to_string()
42+
}
43+
InvalidSuppressionCommentKind::Invalid(InvalidSuppressionKind::Unmatched) => {
44+
"no matching 'disable' comment".to_string()
45+
}
46+
InvalidSuppressionCommentKind::Error(error) => format!("{error}"),
47+
};
48+
format!("Invalid suppression comment: {msg}")
49+
}
50+
51+
fn fix_title(&self) -> String {
52+
"Remove suppression comment".to_string()
53+
}
54+
}
55+
56+
pub(crate) enum InvalidSuppressionCommentKind {
57+
Invalid(InvalidSuppressionKind),
58+
Error(ParseErrorKind),
59+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub(crate) use invalid_formatter_suppression_comment::*;
2222
pub(crate) use invalid_index_type::*;
2323
pub(crate) use invalid_pyproject_toml::*;
2424
pub(crate) use invalid_rule_code::*;
25+
pub(crate) use invalid_suppression_comment::*;
2526
pub(crate) use legacy_form_pytest_raises::*;
2627
pub(crate) use logging_eager_conversion::*;
2728
pub(crate) use map_int_version_parsing::*;
@@ -46,6 +47,7 @@ pub(crate) use starmap_zip::*;
4647
pub(crate) use static_key_dict_comprehension::*;
4748
#[cfg(any(feature = "test-rules", test))]
4849
pub(crate) use test_rules::*;
50+
pub(crate) use unmatched_suppression_comment::*;
4951
pub(crate) use unnecessary_cast_to_int::*;
5052
pub(crate) use unnecessary_iterable_allocation_for_first_element::*;
5153
pub(crate) use unnecessary_key_check::*;
@@ -87,6 +89,7 @@ mod invalid_formatter_suppression_comment;
8789
mod invalid_index_type;
8890
mod invalid_pyproject_toml;
8991
mod invalid_rule_code;
92+
mod invalid_suppression_comment;
9093
mod legacy_form_pytest_raises;
9194
mod logging_eager_conversion;
9295
mod map_int_version_parsing;
@@ -113,6 +116,7 @@ mod static_key_dict_comprehension;
113116
mod suppression_comment_visitor;
114117
#[cfg(any(feature = "test-rules", test))]
115118
pub(crate) mod test_rules;
119+
mod unmatched_suppression_comment;
116120
mod unnecessary_cast_to_int;
117121
mod unnecessary_iterable_allocation_for_first_element;
118122
mod unnecessary_key_check;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
use ruff_macros::{ViolationMetadata, derive_message_formats};
2+
3+
use crate::Violation;
4+
5+
/// ## What it does
6+
/// Checks for unmatched range suppression comments
7+
///
8+
/// ## Why is this bad?
9+
/// Unmatched range suppression comments can inadvertently suppress violations
10+
/// over larger sections of code than intended, particularly at module scope.
11+
///
12+
/// ## Example
13+
/// ```python
14+
/// def foo():
15+
/// # ruff: disable[E501] # unmatched
16+
/// REALLY_LONG_VALUES = [...]
17+
///
18+
/// print(REALLY_LONG_VALUES)
19+
/// ```
20+
///
21+
/// Use instead:
22+
/// ```python
23+
/// def foo():
24+
/// # ruff: disable[E501]
25+
/// REALLY_LONG_VALUES = [...]
26+
/// # ruff: enable[E501]
27+
///
28+
/// print(REALLY_LONG_VALUES)
29+
/// ```
30+
///
31+
/// ## References
32+
/// - [Ruff error suppression](https://docs.astral.sh/ruff/linter/#error-suppression)
33+
#[derive(ViolationMetadata)]
34+
#[violation_metadata(preview_since = "0.14.9")]
35+
pub(crate) struct UnmatchedSuppressionComment;
36+
37+
impl Violation for UnmatchedSuppressionComment {
38+
#[derive_message_formats]
39+
fn message(&self) -> String {
40+
"Suppression comment without matching `#ruff:enable` comment".to_string()
41+
}
42+
}

0 commit comments

Comments
 (0)