Skip to content

Commit

Permalink
Expand expressions to include parentheses in E712 (#6575)
Browse files Browse the repository at this point in the history
## 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.
  • Loading branch information
charliermarsh authored Aug 17, 2023
1 parent db1c556 commit 1050142
Show file tree
Hide file tree
Showing 9 changed files with 208 additions and 14 deletions.
6 changes: 6 additions & 0 deletions crates/ruff/resources/test/fixtures/pycodestyle/E712.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
if res == True != False:
pass

if(True) == TrueElement or x == TrueElement:
pass

if (yield i) == True:
print("even")

#: Okay
if x not in y:
pass
Expand Down
18 changes: 14 additions & 4 deletions crates/ruff/src/rules/pycodestyle/helpers.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use ruff_python_ast::{CmpOp, Expr, Ranged};
use ruff_text_size::{TextLen, TextRange};
use unicode_width::UnicodeWidthStr;

use ruff_python_ast::node::AnyNodeRef;
use ruff_python_ast::parenthesize::parenthesized_range;
use ruff_python_ast::{CmpOp, Expr, Ranged};
use ruff_source_file::{Line, Locator};
use ruff_text_size::{TextLen, TextRange};

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

Expand All @@ -14,14 +16,17 @@ pub(super) fn generate_comparison(
left: &Expr,
ops: &[CmpOp],
comparators: &[Expr],
parent: AnyNodeRef,
locator: &Locator,
) -> String {
let start = left.start();
let end = comparators.last().map_or_else(|| left.end(), Ranged::end);
let mut contents = String::with_capacity(usize::from(end - start));

// Add the left side of the comparison.
contents.push_str(locator.slice(left.range()));
contents.push_str(locator.slice(
parenthesized_range(left.into(), parent, locator.contents()).unwrap_or(left.range()),
));

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

// Add the right side of the comparison.
contents.push_str(locator.slice(comparator.range()));
contents.push_str(
locator.slice(
parenthesized_range(comparator.into(), parent, locator.contents())
.unwrap_or(comparator.range()),
),
);
}

contents
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,13 @@ pub(crate) fn literal_comparisons(checker: &mut Checker, compare: &ast::ExprComp
.map(|(idx, op)| bad_ops.get(&idx).unwrap_or(op))
.copied()
.collect::<Vec<_>>();
let content =
generate_comparison(&compare.left, &ops, &compare.comparators, checker.locator());
let content = generate_comparison(
&compare.left,
&ops,
&compare.comparators,
compare.into(),
checker.locator(),
);
for diagnostic in &mut diagnostics {
diagnostic.set_fix(Fix::suggested(Edit::range_replacement(
content.to_string(),
Expand Down
16 changes: 14 additions & 2 deletions crates/ruff/src/rules/pycodestyle/rules/not_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,13 @@ pub(crate) fn not_tests(checker: &mut Checker, unary_op: &ast::ExprUnaryOp) {
let mut diagnostic = Diagnostic::new(NotInTest, unary_op.operand.range());
if checker.patch(diagnostic.kind.rule()) {
diagnostic.set_fix(Fix::automatic(Edit::range_replacement(
generate_comparison(left, &[CmpOp::NotIn], comparators, checker.locator()),
generate_comparison(
left,
&[CmpOp::NotIn],
comparators,
unary_op.into(),
checker.locator(),
),
unary_op.range(),
)));
}
Expand All @@ -106,7 +112,13 @@ pub(crate) fn not_tests(checker: &mut Checker, unary_op: &ast::ExprUnaryOp) {
let mut diagnostic = Diagnostic::new(NotIsTest, unary_op.operand.range());
if checker.patch(diagnostic.kind.rule()) {
diagnostic.set_fix(Fix::automatic(Edit::range_replacement(
generate_comparison(left, &[CmpOp::IsNot], comparators, checker.locator()),
generate_comparison(
left,
&[CmpOp::IsNot],
comparators,
unary_op.into(),
checker.locator(),
),
unary_op.range(),
)));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ E712.py:22:5: E712 [*] Comparison to `True` should be `cond is True` or `if cond
20 20 | var = 1 if cond == True else -1 if cond == False else cond
21 21 | #: E712
22 |-if (True) == TrueElement or x == TrueElement:
22 |+if True is TrueElement or x == TrueElement:
22 |+if (True) is TrueElement or x == TrueElement:
23 23 | pass
24 24 |
25 25 | if res == True != False:
Expand All @@ -204,7 +204,7 @@ E712.py:25:11: E712 [*] Comparison to `True` should be `cond is True` or `if con
25 |+if res is True is not False:
26 26 | pass
27 27 |
28 28 | #: Okay
28 28 | if(True) == TrueElement or x == TrueElement:

E712.py:25:19: E712 [*] Comparison to `False` should be `cond is not False` or `if cond:`
|
Expand All @@ -224,6 +224,46 @@ E712.py:25:19: E712 [*] Comparison to `False` should be `cond is not False` or `
25 |+if res is True is not False:
26 26 | pass
27 27 |
28 28 | #: Okay
28 28 | if(True) == TrueElement or x == TrueElement:

E712.py:28:4: E712 [*] Comparison to `True` should be `cond is True` or `if cond:`
|
26 | pass
27 |
28 | if(True) == TrueElement or x == TrueElement:
| ^^^^ E712
29 | pass
|
= help: Replace with `cond is True`

Suggested fix
25 25 | if res == True != False:
26 26 | pass
27 27 |
28 |-if(True) == TrueElement or x == TrueElement:
28 |+if(True) is TrueElement or x == TrueElement:
29 29 | pass
30 30 |
31 31 | if (yield i) == True:

E712.py:31:17: E712 [*] Comparison to `True` should be `cond is True` or `if cond:`
|
29 | pass
30 |
31 | if (yield i) == True:
| ^^^^ E712
32 | print("even")
|
= help: Replace with `cond is True`

Suggested fix
28 28 | if(True) == TrueElement or x == TrueElement:
29 29 | pass
30 30 |
31 |-if (yield i) == True:
31 |+if (yield i) is True:
32 32 | print("even")
33 33 |
34 34 | #: Okay


1 change: 1 addition & 0 deletions crates/ruff_python_ast/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod identifier;
pub mod imports;
pub mod node;
mod nodes;
pub mod parenthesize;
pub mod relocate;
pub mod statement_visitor;
pub mod stmt_if;
Expand Down
47 changes: 47 additions & 0 deletions crates/ruff_python_ast/src/parenthesize.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer};
use ruff_text_size::{TextRange, TextSize};

use crate::node::AnyNodeRef;
use crate::{ExpressionRef, Ranged};

/// Returns the [`TextRange`] of a given expression including parentheses, if the expression is
/// parenthesized; or `None`, if the expression is not parenthesized.
pub fn parenthesized_range(
expr: ExpressionRef,
parent: AnyNodeRef,
contents: &str,
) -> Option<TextRange> {
// If the parent is a node that brings its own parentheses, exclude the closing parenthesis
// from our search range. Otherwise, we risk matching on calls, like `func(x)`, for which
// the open and close parentheses are part of the `Arguments` node.
//
// There are a few other nodes that may have their own parentheses, but are fine to exclude:
// - `Parameters`: The parameters to a function definition. Any expressions would represent
// default arguments, and so must be preceded by _at least_ the parameter name. As such,
// we won't mistake any parentheses for the opening and closing parentheses on the
// `Parameters` node itself.
// - `Tuple`: The elements of a tuple. The only risk is a single-element tuple (e.g., `(x,)`),
// which must have a trailing comma anyway.
let exclusive_parent_end = if parent.is_arguments() {
parent.end() - TextSize::new(1)
} else {
parent.end()
};

// First, test if there's a closing parenthesis because it tends to be cheaper.
let tokenizer =
SimpleTokenizer::new(contents, TextRange::new(expr.end(), exclusive_parent_end));
let right = tokenizer.skip_trivia().next()?;

if right.kind == SimpleTokenKind::RParen {
// Next, test for the opening parenthesis.
let mut tokenizer =
SimpleTokenizer::up_to_without_back_comment(expr.start(), contents).skip_trivia();
let left = tokenizer.next_back()?;
if left.kind == SimpleTokenKind::LParen {
return Some(TextRange::new(left.start(), right.end()));
}
}

None
}
3 changes: 0 additions & 3 deletions crates/ruff_python_ast/src/stmt_if.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,3 @@ pub fn if_elif_branches(stmt_if: &StmtIf) -> impl Iterator<Item = IfElifBranch>
})
}))
}

#[cfg(test)]
mod test {}
76 changes: 76 additions & 0 deletions crates/ruff_python_ast/tests/parenthesize.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
use ruff_python_ast::parenthesize::parenthesized_range;
use ruff_python_parser::parse_expression;

#[test]
fn test_parenthesized_name() {
let source_code = r#"(x) + 1"#;
let expr = parse_expression(source_code, "<filename>").unwrap();

let bin_op = expr.as_bin_op_expr().unwrap();
let name = bin_op.left.as_ref();

let parenthesized = parenthesized_range(name.into(), bin_op.into(), source_code);
assert!(parenthesized.is_some());
}

#[test]
fn test_non_parenthesized_name() {
let source_code = r#"x + 1"#;
let expr = parse_expression(source_code, "<filename>").unwrap();

let bin_op = expr.as_bin_op_expr().unwrap();
let name = bin_op.left.as_ref();

let parenthesized = parenthesized_range(name.into(), bin_op.into(), source_code);
assert!(parenthesized.is_none());
}

#[test]
fn test_parenthesized_argument() {
let source_code = r#"f((a))"#;
let expr = parse_expression(source_code, "<filename>").unwrap();

let call = expr.as_call_expr().unwrap();
let arguments = &call.arguments;
let argument = arguments.args.first().unwrap();

let parenthesized = parenthesized_range(argument.into(), arguments.into(), source_code);
assert!(parenthesized.is_some());
}

#[test]
fn test_non_parenthesized_argument() {
let source_code = r#"f(a)"#;
let expr = parse_expression(source_code, "<filename>").unwrap();

let call = expr.as_call_expr().unwrap();
let arguments = &call.arguments;
let argument = arguments.args.first().unwrap();

let parenthesized = parenthesized_range(argument.into(), arguments.into(), source_code);
assert!(parenthesized.is_none());
}

#[test]
fn test_parenthesized_tuple_member() {
let source_code = r#"(a, (b))"#;
let expr = parse_expression(source_code, "<filename>").unwrap();

let tuple = expr.as_tuple_expr().unwrap();
let member = tuple.elts.last().unwrap();

let parenthesized = parenthesized_range(member.into(), tuple.into(), source_code);
assert!(parenthesized.is_some());
}

#[test]
fn test_non_parenthesized_tuple_member() {
let source_code = r#"(a, b)"#;
let expr = parse_expression(source_code, "<filename>").unwrap();

let tuple = expr.as_tuple_expr().unwrap();
let member = tuple.elts.last().unwrap();

let parenthesized = parenthesized_range(member.into(), tuple.into(), source_code);
assert!(parenthesized.is_none());
}

0 comments on commit 1050142

Please sign in to comment.