Skip to content

Commit 1050142

Browse files
Expand expressions to include parentheses in E712 (#6575)
## Summary This PR exposes our `is_expression_parenthesized` logic such that we can use it to expand expressions when autofixing to include their parenthesized ranges. This solution has a few drawbacks: (1) we need to compute parenthesized ranges in more places, which also relies on backwards lexing; and (2) we need to make use of this in any relevant fixes. However, I still think it's worth pursuing. On (1), the implementation is very contained, so IMO we can easily swap this out for a more performant solution in the future if needed. On (2), this improves correctness and fixes some bad syntax errors detected by fuzzing, which means it has value even if it's not as robust as an _actual_ `ParenthesizedExpression` node in the AST itself. Closes #4925. ## Test Plan `cargo test` with new cases that previously failed the fuzzer.
1 parent db1c556 commit 1050142

File tree

9 files changed

+208
-14
lines changed

9 files changed

+208
-14
lines changed

crates/ruff/resources/test/fixtures/pycodestyle/E712.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
if res == True != False:
2626
pass
2727

28+
if(True) == TrueElement or x == TrueElement:
29+
pass
30+
31+
if (yield i) == True:
32+
print("even")
33+
2834
#: Okay
2935
if x not in y:
3036
pass

crates/ruff/src/rules/pycodestyle/helpers.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
use ruff_python_ast::{CmpOp, Expr, Ranged};
2-
use ruff_text_size::{TextLen, TextRange};
31
use unicode_width::UnicodeWidthStr;
42

3+
use ruff_python_ast::node::AnyNodeRef;
4+
use ruff_python_ast::parenthesize::parenthesized_range;
5+
use ruff_python_ast::{CmpOp, Expr, Ranged};
56
use ruff_source_file::{Line, Locator};
7+
use ruff_text_size::{TextLen, TextRange};
68

79
use crate::line_width::{LineLength, LineWidth, TabSize};
810

@@ -14,14 +16,17 @@ pub(super) fn generate_comparison(
1416
left: &Expr,
1517
ops: &[CmpOp],
1618
comparators: &[Expr],
19+
parent: AnyNodeRef,
1720
locator: &Locator,
1821
) -> String {
1922
let start = left.start();
2023
let end = comparators.last().map_or_else(|| left.end(), Ranged::end);
2124
let mut contents = String::with_capacity(usize::from(end - start));
2225

2326
// Add the left side of the comparison.
24-
contents.push_str(locator.slice(left.range()));
27+
contents.push_str(locator.slice(
28+
parenthesized_range(left.into(), parent, locator.contents()).unwrap_or(left.range()),
29+
));
2530

2631
for (op, comparator) in ops.iter().zip(comparators) {
2732
// Add the operator.
@@ -39,7 +44,12 @@ pub(super) fn generate_comparison(
3944
});
4045

4146
// Add the right side of the comparison.
42-
contents.push_str(locator.slice(comparator.range()));
47+
contents.push_str(
48+
locator.slice(
49+
parenthesized_range(comparator.into(), parent, locator.contents())
50+
.unwrap_or(comparator.range()),
51+
),
52+
);
4353
}
4454

4555
contents

crates/ruff/src/rules/pycodestyle/rules/literal_comparisons.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,8 +279,13 @@ pub(crate) fn literal_comparisons(checker: &mut Checker, compare: &ast::ExprComp
279279
.map(|(idx, op)| bad_ops.get(&idx).unwrap_or(op))
280280
.copied()
281281
.collect::<Vec<_>>();
282-
let content =
283-
generate_comparison(&compare.left, &ops, &compare.comparators, checker.locator());
282+
let content = generate_comparison(
283+
&compare.left,
284+
&ops,
285+
&compare.comparators,
286+
compare.into(),
287+
checker.locator(),
288+
);
284289
for diagnostic in &mut diagnostics {
285290
diagnostic.set_fix(Fix::suggested(Edit::range_replacement(
286291
content.to_string(),

crates/ruff/src/rules/pycodestyle/rules/not_tests.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,13 @@ pub(crate) fn not_tests(checker: &mut Checker, unary_op: &ast::ExprUnaryOp) {
9494
let mut diagnostic = Diagnostic::new(NotInTest, unary_op.operand.range());
9595
if checker.patch(diagnostic.kind.rule()) {
9696
diagnostic.set_fix(Fix::automatic(Edit::range_replacement(
97-
generate_comparison(left, &[CmpOp::NotIn], comparators, checker.locator()),
97+
generate_comparison(
98+
left,
99+
&[CmpOp::NotIn],
100+
comparators,
101+
unary_op.into(),
102+
checker.locator(),
103+
),
98104
unary_op.range(),
99105
)));
100106
}
@@ -106,7 +112,13 @@ pub(crate) fn not_tests(checker: &mut Checker, unary_op: &ast::ExprUnaryOp) {
106112
let mut diagnostic = Diagnostic::new(NotIsTest, unary_op.operand.range());
107113
if checker.patch(diagnostic.kind.rule()) {
108114
diagnostic.set_fix(Fix::automatic(Edit::range_replacement(
109-
generate_comparison(left, &[CmpOp::IsNot], comparators, checker.locator()),
115+
generate_comparison(
116+
left,
117+
&[CmpOp::IsNot],
118+
comparators,
119+
unary_op.into(),
120+
checker.locator(),
121+
),
110122
unary_op.range(),
111123
)));
112124
}

crates/ruff/src/rules/pycodestyle/snapshots/ruff__rules__pycodestyle__tests__E712_E712.py.snap

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ E712.py:22:5: E712 [*] Comparison to `True` should be `cond is True` or `if cond
181181
20 20 | var = 1 if cond == True else -1 if cond == False else cond
182182
21 21 | #: E712
183183
22 |-if (True) == TrueElement or x == TrueElement:
184-
22 |+if True is TrueElement or x == TrueElement:
184+
22 |+if (True) is TrueElement or x == TrueElement:
185185
23 23 | pass
186186
24 24 |
187187
25 25 | if res == True != False:
@@ -204,7 +204,7 @@ E712.py:25:11: E712 [*] Comparison to `True` should be `cond is True` or `if con
204204
25 |+if res is True is not False:
205205
26 26 | pass
206206
27 27 |
207-
28 28 | #: Okay
207+
28 28 | if(True) == TrueElement or x == TrueElement:
208208

209209
E712.py:25:19: E712 [*] Comparison to `False` should be `cond is not False` or `if cond:`
210210
|
@@ -224,6 +224,46 @@ E712.py:25:19: E712 [*] Comparison to `False` should be `cond is not False` or `
224224
25 |+if res is True is not False:
225225
26 26 | pass
226226
27 27 |
227-
28 28 | #: Okay
227+
28 28 | if(True) == TrueElement or x == TrueElement:
228+
229+
E712.py:28:4: E712 [*] Comparison to `True` should be `cond is True` or `if cond:`
230+
|
231+
26 | pass
232+
27 |
233+
28 | if(True) == TrueElement or x == TrueElement:
234+
| ^^^^ E712
235+
29 | pass
236+
|
237+
= help: Replace with `cond is True`
238+
239+
Suggested fix
240+
25 25 | if res == True != False:
241+
26 26 | pass
242+
27 27 |
243+
28 |-if(True) == TrueElement or x == TrueElement:
244+
28 |+if(True) is TrueElement or x == TrueElement:
245+
29 29 | pass
246+
30 30 |
247+
31 31 | if (yield i) == True:
248+
249+
E712.py:31:17: E712 [*] Comparison to `True` should be `cond is True` or `if cond:`
250+
|
251+
29 | pass
252+
30 |
253+
31 | if (yield i) == True:
254+
| ^^^^ E712
255+
32 | print("even")
256+
|
257+
= help: Replace with `cond is True`
258+
259+
Suggested fix
260+
28 28 | if(True) == TrueElement or x == TrueElement:
261+
29 29 | pass
262+
30 30 |
263+
31 |-if (yield i) == True:
264+
31 |+if (yield i) is True:
265+
32 32 | print("even")
266+
33 33 |
267+
34 34 | #: Okay
228268

229269

crates/ruff_python_ast/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod identifier;
1212
pub mod imports;
1313
pub mod node;
1414
mod nodes;
15+
pub mod parenthesize;
1516
pub mod relocate;
1617
pub mod statement_visitor;
1718
pub mod stmt_if;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer};
2+
use ruff_text_size::{TextRange, TextSize};
3+
4+
use crate::node::AnyNodeRef;
5+
use crate::{ExpressionRef, Ranged};
6+
7+
/// Returns the [`TextRange`] of a given expression including parentheses, if the expression is
8+
/// parenthesized; or `None`, if the expression is not parenthesized.
9+
pub fn parenthesized_range(
10+
expr: ExpressionRef,
11+
parent: AnyNodeRef,
12+
contents: &str,
13+
) -> Option<TextRange> {
14+
// If the parent is a node that brings its own parentheses, exclude the closing parenthesis
15+
// from our search range. Otherwise, we risk matching on calls, like `func(x)`, for which
16+
// the open and close parentheses are part of the `Arguments` node.
17+
//
18+
// There are a few other nodes that may have their own parentheses, but are fine to exclude:
19+
// - `Parameters`: The parameters to a function definition. Any expressions would represent
20+
// default arguments, and so must be preceded by _at least_ the parameter name. As such,
21+
// we won't mistake any parentheses for the opening and closing parentheses on the
22+
// `Parameters` node itself.
23+
// - `Tuple`: The elements of a tuple. The only risk is a single-element tuple (e.g., `(x,)`),
24+
// which must have a trailing comma anyway.
25+
let exclusive_parent_end = if parent.is_arguments() {
26+
parent.end() - TextSize::new(1)
27+
} else {
28+
parent.end()
29+
};
30+
31+
// First, test if there's a closing parenthesis because it tends to be cheaper.
32+
let tokenizer =
33+
SimpleTokenizer::new(contents, TextRange::new(expr.end(), exclusive_parent_end));
34+
let right = tokenizer.skip_trivia().next()?;
35+
36+
if right.kind == SimpleTokenKind::RParen {
37+
// Next, test for the opening parenthesis.
38+
let mut tokenizer =
39+
SimpleTokenizer::up_to_without_back_comment(expr.start(), contents).skip_trivia();
40+
let left = tokenizer.next_back()?;
41+
if left.kind == SimpleTokenKind::LParen {
42+
return Some(TextRange::new(left.start(), right.end()));
43+
}
44+
}
45+
46+
None
47+
}

crates/ruff_python_ast/src/stmt_if.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,3 @@ pub fn if_elif_branches(stmt_if: &StmtIf) -> impl Iterator<Item = IfElifBranch>
4646
})
4747
}))
4848
}
49-
50-
#[cfg(test)]
51-
mod test {}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
use ruff_python_ast::parenthesize::parenthesized_range;
2+
use ruff_python_parser::parse_expression;
3+
4+
#[test]
5+
fn test_parenthesized_name() {
6+
let source_code = r#"(x) + 1"#;
7+
let expr = parse_expression(source_code, "<filename>").unwrap();
8+
9+
let bin_op = expr.as_bin_op_expr().unwrap();
10+
let name = bin_op.left.as_ref();
11+
12+
let parenthesized = parenthesized_range(name.into(), bin_op.into(), source_code);
13+
assert!(parenthesized.is_some());
14+
}
15+
16+
#[test]
17+
fn test_non_parenthesized_name() {
18+
let source_code = r#"x + 1"#;
19+
let expr = parse_expression(source_code, "<filename>").unwrap();
20+
21+
let bin_op = expr.as_bin_op_expr().unwrap();
22+
let name = bin_op.left.as_ref();
23+
24+
let parenthesized = parenthesized_range(name.into(), bin_op.into(), source_code);
25+
assert!(parenthesized.is_none());
26+
}
27+
28+
#[test]
29+
fn test_parenthesized_argument() {
30+
let source_code = r#"f((a))"#;
31+
let expr = parse_expression(source_code, "<filename>").unwrap();
32+
33+
let call = expr.as_call_expr().unwrap();
34+
let arguments = &call.arguments;
35+
let argument = arguments.args.first().unwrap();
36+
37+
let parenthesized = parenthesized_range(argument.into(), arguments.into(), source_code);
38+
assert!(parenthesized.is_some());
39+
}
40+
41+
#[test]
42+
fn test_non_parenthesized_argument() {
43+
let source_code = r#"f(a)"#;
44+
let expr = parse_expression(source_code, "<filename>").unwrap();
45+
46+
let call = expr.as_call_expr().unwrap();
47+
let arguments = &call.arguments;
48+
let argument = arguments.args.first().unwrap();
49+
50+
let parenthesized = parenthesized_range(argument.into(), arguments.into(), source_code);
51+
assert!(parenthesized.is_none());
52+
}
53+
54+
#[test]
55+
fn test_parenthesized_tuple_member() {
56+
let source_code = r#"(a, (b))"#;
57+
let expr = parse_expression(source_code, "<filename>").unwrap();
58+
59+
let tuple = expr.as_tuple_expr().unwrap();
60+
let member = tuple.elts.last().unwrap();
61+
62+
let parenthesized = parenthesized_range(member.into(), tuple.into(), source_code);
63+
assert!(parenthesized.is_some());
64+
}
65+
66+
#[test]
67+
fn test_non_parenthesized_tuple_member() {
68+
let source_code = r#"(a, b)"#;
69+
let expr = parse_expression(source_code, "<filename>").unwrap();
70+
71+
let tuple = expr.as_tuple_expr().unwrap();
72+
let member = tuple.elts.last().unwrap();
73+
74+
let parenthesized = parenthesized_range(member.into(), tuple.into(), source_code);
75+
assert!(parenthesized.is_none());
76+
}

0 commit comments

Comments
 (0)