[`pylint`] Include parentheses and multiple comparators in check for `boolean-chained-comparison (PLR1716)` (#14781)

This PR introduces three changes to the diagnostic and fix behavior
(still under preview) for [boolean-chained-comparison
(PLR1716)](https://docs.astral.sh/ruff/rules/boolean-chained-comparison/#boolean-chained-comparison-plr1716).

1. We now offer a _fix_ in the case of parenthesized expressions like
`(a < b) and b < c`. The fix will merge the chains of comparisons and
then balance parentheses by _adding_ parentheses to one side of the
expression.
2. We now trigger a diagnostic (and fix) in the case where some
comparisons have multiple comparators like `a < b < c and c < d`.
3. When adjacent comparators are parenthesized, we prefer the left
parenthesization and apply the replacement to the whole parenthesized
range. So, for example, `a < (b) and ((b)) < c` becomes `a < (b) < c`.

While these seem like somewhat disconnected changes, they are actually
related. If we only offered (1), then we would see the following fix
behavior:

```diff
- (a < b) and b < c and ((c < d))
+ (a < b < c) and ((c < d))
```

This is because the fix which add parentheses to the first pair of
comparisons overlaps with the fix that removes the `and` between the
second two comparisons. So the latter fix is deferred. However, the
latter fix does not get a second chance because, upon the next lint
iteration, there is no violation of `PLR1716`.

Upon adopting (2), however, both fixes occur by the time ruff completes
several iterations and we get:

```diff
- (a < b) and b < c and ((c < d))
+ ((a < b < c < d))
```

Finally, (3) fixes a previously unobserved bug wherein the autofix for
`a < (b) and b < c` used to result in `a<(b<c` which gives a syntax
error. It could in theory have been fixed in a separate PR, but seems to
be on theme here.


----------

- Closes #13524
- (1), (2), and (3) are implemented in separate commits for ease of
review and modification.
- Technically a user can trigger an error in ruff (by reaching max
iterations) if they have a humongous boolean chained comparison with
differing parentheses levels.
This commit is contained in:
Dylan 2024-12-08 22:58:45 -06:00 committed by GitHub
parent b56b3c813c
commit 9d641fa714
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 300 additions and 72 deletions

View File

@ -119,10 +119,30 @@ c = int(input())
if a > b and b < c: if a > b and b < c:
pass pass
# fixes will balance parentheses
# Unfixable due to parentheses.
(a < b) and b < c (a < b) and b < c
a < b and (b < c) a < b and (b < c)
((a < b) and b < c) ((a < b) and b < c)
(a < b) and (b < c) (a < b) and (b < c)
(((a < b))) and (b < c) (((a < b))) and (b < c)
(a<b) and b<c and ((c<d))
# should error and fix
a<b<c and c<d
# more involved examples (all should error and fix)
a < ( # sneaky comment
b
# more comments
) and b < c
(
a
<b
# hmmm...
<c
and ((c<d))
)
a < (b) and (((b)) < c)

View File

@ -1,8 +1,9 @@
use itertools::Itertools; use itertools::Itertools;
use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation}; use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
use ruff_macros::{derive_message_formats, ViolationMetadata}; use ruff_macros::{derive_message_formats, ViolationMetadata};
use ruff_python_ast::{ use ruff_python_ast::{
parenthesize::parenthesized_range, BoolOp, CmpOp, Expr, ExprBoolOp, ExprCompare, parenthesize::{parentheses_iterator, parenthesized_range},
BoolOp, CmpOp, Expr, ExprBoolOp, ExprCompare,
}; };
use ruff_text_size::{Ranged, TextRange}; use ruff_text_size::{Ranged, TextRange};
@ -36,16 +37,14 @@ use crate::checkers::ast::Checker;
#[derive(ViolationMetadata)] #[derive(ViolationMetadata)]
pub(crate) struct BooleanChainedComparison; pub(crate) struct BooleanChainedComparison;
impl Violation for BooleanChainedComparison { impl AlwaysFixableViolation for BooleanChainedComparison {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats] #[derive_message_formats]
fn message(&self) -> String { fn message(&self) -> String {
"Contains chained boolean comparison that can be simplified".to_string() "Contains chained boolean comparison that can be simplified".to_string()
} }
fn fix_title(&self) -> Option<String> { fn fix_title(&self) -> String {
Some("Use a single compare expression".to_string()) "Use a single compare expression".to_string()
} }
} }
@ -64,11 +63,11 @@ pub(crate) fn boolean_chained_comparison(checker: &mut Checker, expr_bool_op: &E
let locator = checker.locator(); let locator = checker.locator();
let comment_ranges = checker.comment_ranges(); let comment_ranges = checker.comment_ranges();
// retrieve all compare statements from expression // retrieve all compare expressions from boolean expression
let compare_expressions = expr_bool_op let compare_expressions = expr_bool_op
.values .values
.iter() .iter()
.map(|stmt| stmt.as_compare_expr().unwrap()); .map(|expr| expr.as_compare_expr().unwrap());
let diagnostics = compare_expressions let diagnostics = compare_expressions
.tuple_windows() .tuple_windows()
@ -76,7 +75,7 @@ pub(crate) fn boolean_chained_comparison(checker: &mut Checker, expr_bool_op: &E
are_compare_expr_simplifiable(left_compare, right_compare) are_compare_expr_simplifiable(left_compare, right_compare)
}) })
.filter_map(|(left_compare, right_compare)| { .filter_map(|(left_compare, right_compare)| {
let Expr::Name(left_compare_right) = left_compare.comparators.first()? else { let Expr::Name(left_compare_right) = left_compare.comparators.last()? else {
return None; return None;
}; };
@ -88,33 +87,65 @@ pub(crate) fn boolean_chained_comparison(checker: &mut Checker, expr_bool_op: &E
return None; return None;
} }
let left_has_paren = parenthesized_range( let left_paren_count = parentheses_iterator(
left_compare.into(), left_compare.into(),
expr_bool_op.into(), Some(expr_bool_op.into()),
comment_ranges, comment_ranges,
locator.contents(), locator.contents(),
) )
.is_some(); .count();
let right_has_paren = parenthesized_range( let right_paren_count = parentheses_iterator(
right_compare.into(), right_compare.into(),
expr_bool_op.into(), Some(expr_bool_op.into()),
comment_ranges, comment_ranges,
locator.contents(), locator.contents(),
) )
.is_some(); .count();
// Do not offer a fix if there are any parentheses // Create the edit that removes the comparison operator
// TODO: We can support a fix here, we just need to be careful to balance the
// parentheses which requires a more sophisticated edit // In `a<(b) and ((b))<c`, we need to handle the
let fix = if left_has_paren || right_has_paren { // parentheses when specifying the fix range.
None let left_compare_right_range = parenthesized_range(
} else { left_compare_right.into(),
let edit = Edit::range_replacement( left_compare.into(),
left_compare_right.id().to_string(), comment_ranges,
TextRange::new(left_compare_right.start(), right_compare_left.end()), locator.contents(),
); )
Some(Fix::safe_edit(edit)) .unwrap_or(left_compare_right.range());
let right_compare_left_range = parenthesized_range(
right_compare_left.into(),
right_compare.into(),
comment_ranges,
locator.contents(),
)
.unwrap_or(right_compare_left.range());
let edit = Edit::range_replacement(
locator.slice(left_compare_right_range).to_string(),
TextRange::new(
left_compare_right_range.start(),
right_compare_left_range.end(),
),
);
// Balance left and right parentheses
let fix = match left_paren_count.cmp(&right_paren_count) {
std::cmp::Ordering::Less => {
let balance_parens_edit = Edit::insertion(
"(".repeat(right_paren_count - left_paren_count),
left_compare.start(),
);
Fix::safe_edits(edit, [balance_parens_edit])
}
std::cmp::Ordering::Equal => Fix::safe_edit(edit),
std::cmp::Ordering::Greater => {
let balance_parens_edit = Edit::insertion(
")".repeat(left_paren_count - right_paren_count),
right_compare.end(),
);
Fix::safe_edits(edit, [balance_parens_edit])
}
}; };
let mut diagnostic = Diagnostic::new( let mut diagnostic = Diagnostic::new(
@ -122,9 +153,7 @@ pub(crate) fn boolean_chained_comparison(checker: &mut Checker, expr_bool_op: &E
TextRange::new(left_compare.start(), right_compare.end()), TextRange::new(left_compare.start(), right_compare.end()),
); );
if let Some(fix) = fix { diagnostic.set_fix(fix);
diagnostic.set_fix(fix);
}
Some(diagnostic) Some(diagnostic)
}); });
@ -134,17 +163,15 @@ pub(crate) fn boolean_chained_comparison(checker: &mut Checker, expr_bool_op: &E
/// Checks whether two compare expressions are simplifiable /// Checks whether two compare expressions are simplifiable
fn are_compare_expr_simplifiable(left: &ExprCompare, right: &ExprCompare) -> bool { fn are_compare_expr_simplifiable(left: &ExprCompare, right: &ExprCompare) -> bool {
let [left_operator] = &*left.ops else { left.ops
return false; .iter()
}; .chain(right.ops.iter())
.tuple_windows::<(_, _)>()
let [right_operator] = &*right.ops else { .all(|(left_operator, right_operator)| {
return false; matches!(
}; (left_operator, right_operator),
(CmpOp::Lt | CmpOp::LtE, CmpOp::Lt | CmpOp::LtE)
matches!( | (CmpOp::Gt | CmpOp::GtE, CmpOp::Gt | CmpOp::GtE)
(left_operator, right_operator), )
(CmpOp::Lt | CmpOp::LtE, CmpOp::Lt | CmpOp::LtE) })
| (CmpOp::Gt | CmpOp::GtE, CmpOp::Gt | CmpOp::GtE)
)
} }

View File

@ -1,6 +1,5 @@
--- ---
source: crates/ruff_linter/src/rules/pylint/mod.rs source: crates/ruff_linter/src/rules/pylint/mod.rs
snapshot_kind: text
--- ---
boolean_chained_comparison.py:8:4: PLR1716 [*] Contains chained boolean comparison that can be simplified boolean_chained_comparison.py:8:4: PLR1716 [*] Contains chained boolean comparison that can be simplified
| |
@ -262,53 +261,235 @@ boolean_chained_comparison.py:73:24: PLR1716 [*] Contains chained boolean compar
75 75 | 75 75 |
76 76 | # ------------ 76 76 | # ------------
boolean_chained_comparison.py:124:2: PLR1716 Contains chained boolean comparison that can be simplified boolean_chained_comparison.py:123:2: PLR1716 [*] Contains chained boolean comparison that can be simplified
| |
123 | # Unfixable due to parentheses. 122 | # fixes will balance parentheses
124 | (a < b) and b < c 123 | (a < b) and b < c
| ^^^^^^^^^^^^^^^^ PLR1716 | ^^^^^^^^^^^^^^^^ PLR1716
125 | a < b and (b < c) 124 | a < b and (b < c)
126 | ((a < b) and b < c) 125 | ((a < b) and b < c)
| |
= help: Use a single compare expression = help: Use a single compare expression
boolean_chained_comparison.py:125:1: PLR1716 Contains chained boolean comparison that can be simplified Safe fix
120 120 | pass
121 121 |
122 122 | # fixes will balance parentheses
123 |-(a < b) and b < c
123 |+(a < b < c)
124 124 | a < b and (b < c)
125 125 | ((a < b) and b < c)
126 126 | (a < b) and (b < c)
boolean_chained_comparison.py:124:1: PLR1716 [*] Contains chained boolean comparison that can be simplified
| |
123 | # Unfixable due to parentheses. 122 | # fixes will balance parentheses
124 | (a < b) and b < c 123 | (a < b) and b < c
125 | a < b and (b < c) 124 | a < b and (b < c)
| ^^^^^^^^^^^^^^^^ PLR1716 | ^^^^^^^^^^^^^^^^ PLR1716
126 | ((a < b) and b < c) 125 | ((a < b) and b < c)
127 | (a < b) and (b < c) 126 | (a < b) and (b < c)
| |
= help: Use a single compare expression = help: Use a single compare expression
boolean_chained_comparison.py:126:3: PLR1716 Contains chained boolean comparison that can be simplified Safe fix
121 121 |
122 122 | # fixes will balance parentheses
123 123 | (a < b) and b < c
124 |-a < b and (b < c)
124 |+(a < b < c)
125 125 | ((a < b) and b < c)
126 126 | (a < b) and (b < c)
127 127 | (((a < b))) and (b < c)
boolean_chained_comparison.py:125:3: PLR1716 [*] Contains chained boolean comparison that can be simplified
| |
124 | (a < b) and b < c 123 | (a < b) and b < c
125 | a < b and (b < c) 124 | a < b and (b < c)
126 | ((a < b) and b < c) 125 | ((a < b) and b < c)
| ^^^^^^^^^^^^^^^^ PLR1716 | ^^^^^^^^^^^^^^^^ PLR1716
127 | (a < b) and (b < c) 126 | (a < b) and (b < c)
128 | (((a < b))) and (b < c) 127 | (((a < b))) and (b < c)
| |
= help: Use a single compare expression = help: Use a single compare expression
boolean_chained_comparison.py:127:2: PLR1716 Contains chained boolean comparison that can be simplified Safe fix
122 122 | # fixes will balance parentheses
123 123 | (a < b) and b < c
124 124 | a < b and (b < c)
125 |-((a < b) and b < c)
125 |+((a < b < c))
126 126 | (a < b) and (b < c)
127 127 | (((a < b))) and (b < c)
128 128 |
boolean_chained_comparison.py:126:2: PLR1716 [*] Contains chained boolean comparison that can be simplified
| |
125 | a < b and (b < c) 124 | a < b and (b < c)
126 | ((a < b) and b < c) 125 | ((a < b) and b < c)
127 | (a < b) and (b < c) 126 | (a < b) and (b < c)
| ^^^^^^^^^^^^^^^^^ PLR1716 | ^^^^^^^^^^^^^^^^^ PLR1716
128 | (((a < b))) and (b < c) 127 | (((a < b))) and (b < c)
| |
= help: Use a single compare expression = help: Use a single compare expression
boolean_chained_comparison.py:128:4: PLR1716 Contains chained boolean comparison that can be simplified Safe fix
123 123 | (a < b) and b < c
124 124 | a < b and (b < c)
125 125 | ((a < b) and b < c)
126 |-(a < b) and (b < c)
126 |+(a < b < c)
127 127 | (((a < b))) and (b < c)
128 128 |
129 129 | (a<b) and b<c and ((c<d))
boolean_chained_comparison.py:127:4: PLR1716 [*] Contains chained boolean comparison that can be simplified
| |
126 | ((a < b) and b < c) 125 | ((a < b) and b < c)
127 | (a < b) and (b < c) 126 | (a < b) and (b < c)
128 | (((a < b))) and (b < c) 127 | (((a < b))) and (b < c)
| ^^^^^^^^^^^^^^^^^^^ PLR1716 | ^^^^^^^^^^^^^^^^^^^ PLR1716
128 |
129 | (a<b) and b<c and ((c<d))
| |
= help: Use a single compare expression = help: Use a single compare expression
Safe fix
124 124 | a < b and (b < c)
125 125 | ((a < b) and b < c)
126 126 | (a < b) and (b < c)
127 |-(((a < b))) and (b < c)
127 |+(((a < b < c)))
128 128 |
129 129 | (a<b) and b<c and ((c<d))
130 130 |
boolean_chained_comparison.py:129:2: PLR1716 [*] Contains chained boolean comparison that can be simplified
|
127 | (((a < b))) and (b < c)
128 |
129 | (a<b) and b<c and ((c<d))
| ^^^^^^^^^^^^ PLR1716
130 |
131 | # should error and fix
|
= help: Use a single compare expression
Safe fix
126 126 | (a < b) and (b < c)
127 127 | (((a < b))) and (b < c)
128 128 |
129 |-(a<b) and b<c and ((c<d))
129 |+(a<b<c) and ((c<d))
130 130 |
131 131 | # should error and fix
132 132 | a<b<c and c<d
boolean_chained_comparison.py:129:11: PLR1716 [*] Contains chained boolean comparison that can be simplified
|
127 | (((a < b))) and (b < c)
128 |
129 | (a<b) and b<c and ((c<d))
| ^^^^^^^^^^^^^ PLR1716
130 |
131 | # should error and fix
|
= help: Use a single compare expression
Safe fix
126 126 | (a < b) and (b < c)
127 127 | (((a < b))) and (b < c)
128 128 |
129 |-(a<b) and b<c and ((c<d))
129 |+(a<b) and ((b<c<d))
130 130 |
131 131 | # should error and fix
132 132 | a<b<c and c<d
boolean_chained_comparison.py:132:1: PLR1716 [*] Contains chained boolean comparison that can be simplified
|
131 | # should error and fix
132 | a<b<c and c<d
| ^^^^^^^^^^^^^ PLR1716
133 |
134 | # more involved examples (all should error and fix)
|
= help: Use a single compare expression
Safe fix
129 129 | (a<b) and b<c and ((c<d))
130 130 |
131 131 | # should error and fix
132 |-a<b<c and c<d
132 |+a<b<c<d
133 133 |
134 134 | # more involved examples (all should error and fix)
135 135 | a < ( # sneaky comment
boolean_chained_comparison.py:135:1: PLR1716 [*] Contains chained boolean comparison that can be simplified
|
134 | # more involved examples (all should error and fix)
135 | / a < ( # sneaky comment
136 | | b
137 | | # more comments
138 | | ) and b < c
| |___________^ PLR1716
139 |
140 | (
|
= help: Use a single compare expression
Safe fix
135 135 | a < ( # sneaky comment
136 136 | b
137 137 | # more comments
138 |-) and b < c
138 |+) < c
139 139 |
140 140 | (
141 141 | a
boolean_chained_comparison.py:141:5: PLR1716 [*] Contains chained boolean comparison that can be simplified
|
140 | (
141 | a
| _____^
142 | | <b
143 | | # hmmm...
144 | | <c
145 | | and ((c<d))
| |_____________^ PLR1716
146 | )
|
= help: Use a single compare expression
Safe fix
138 138 | ) and b < c
139 139 |
140 140 | (
141 |- a
141 |+ ((a
142 142 | <b
143 143 | # hmmm...
144 |- <c
145 |- and ((c<d))
144 |+ <c<d))
146 145 | )
147 146 |
148 147 | a < (b) and (((b)) < c)
boolean_chained_comparison.py:148:1: PLR1716 [*] Contains chained boolean comparison that can be simplified
|
146 | )
147 |
148 | a < (b) and (((b)) < c)
| ^^^^^^^^^^^^^^^^^^^^^^ PLR1716
|
= help: Use a single compare expression
Safe fix
145 145 | and ((c<d))
146 146 | )
147 147 |
148 |-a < (b) and (((b)) < c)
148 |+(a < (b) < c)