Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[pylint] Implement rule to prefer augmented assignment (PLR6104) #9932

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
some_string = "some string" # PLR6104
index, a_number, to_multiply, to_divide, to_cube, timeDiffSeconds, flags = 0, 1, 2, 3, 4, 5, 0x3 # PLR6104
a_list = [1,2] # PLR6104
some_set = {"elem"} # PLR6104
mat1, mat2 = None, None # PLR6104

some_string = (
some_string
+ "a very long end of string"
) # PLR6104
index = index - 1 # PLR6104
a_list = a_list + ["to concat"] # PLR6104
some_set = some_set | {"to concat"} # PLR6104
to_multiply = to_multiply * 5 # PLR6104
to_divide = to_divide / 5 # PLR6104
to_divide = to_divide // 5 # PLR6104
to_cube = to_cube ** 3 # PLR6104
timeDiffSeconds = timeDiffSeconds % 60 # PLR6104
flags = flags & 0x1 # PLR6104
flags = flags | 0x1 # PLR6104
flags = flags ^ 0x1 # PLR6104
flags = flags << 1 # PLR6104
flags = flags >> 1 # PLR6104
mat1 = mat1 @ mat2 # PLR6104
a_list[1] = a_list[1] + 1 # PLR6104

a_list[0:2] = a_list[0:2] * 3 # PLR6104
a_list[:2] = a_list[:2] * 3 # PLR6104
a_list[1:] = a_list[1:] * 3 # PLR6104
a_list[:] = a_list[:] * 3 # PLR6104

index = index * (index + 10) # PLR6104

class T:
def t(self):
self.a = self.a + 1 # PLR6104

obj = T()
obj.a = obj.a + 1 # PLR6104

a_list[0] = a_list[:] * 3 # OK
index = a_number = a_number + 1 # OK
a_number = index = a_number + 1 # OK
index = index * index + 10 # OK
3 changes: 3 additions & 0 deletions crates/ruff_linter/src/checkers/ast/analyze/statement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1473,6 +1473,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
if checker.settings.rules.enabled(Rule::TypeBivariance) {
pylint::rules::type_bivariance(checker, value);
}
if checker.enabled(Rule::BinaryOpAndNormalAssignment) {
pylint::rules::binary_op_and_normal_assignment(checker, assign);
}
if checker.settings.rules.enabled(Rule::UnsortedDunderAll) {
ruff::rules::sort_dunder_all_assign(checker, assign);
}
Expand Down
1 change: 1 addition & 0 deletions crates/ruff_linter/src/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Pylint, "R2004") => (RuleGroup::Stable, rules::pylint::rules::MagicValueComparison),
(Pylint, "R2044") => (RuleGroup::Preview, rules::pylint::rules::EmptyComment),
(Pylint, "R5501") => (RuleGroup::Stable, rules::pylint::rules::CollapsibleElseIf),
(Pylint, "R6104") => (RuleGroup::Preview, rules::pylint::rules::BinaryOpAndNormalAssignment),
(Pylint, "R6201") => (RuleGroup::Preview, rules::pylint::rules::LiteralMembership),
#[allow(deprecated)]
(Pylint, "R6301") => (RuleGroup::Nursery, rules::pylint::rules::NoSelfUse),
Expand Down
4 changes: 4 additions & 0 deletions crates/ruff_linter/src/rules/pylint/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ mod tests {
Rule::UnnecessaryDictIndexLookup,
Path::new("unnecessary_dict_index_lookup.py")
)]
#[test_case(
Rule::BinaryOpAndNormalAssignment,
Path::new("binary_op_and_normal_assignment.py")
)]
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy());
let diagnostics = test_path(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
use ast::{Expr, StmtAugAssign};
use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast as ast;
use ruff_text_size::{Ranged, TextRange};

use crate::checkers::ast::Checker;

/// ## What it does
/// Checks for normal assignment statements whose target is the same as the
/// left operand of a binary operator, in which cases, augmented assignment
/// could potentially be used instead.
///
/// ## Why is this bad?
/// Augmented assignment operators are more concise to perform a binary
/// operation and assign results back to one of the operands.
///
/// ## Example
/// ```python
/// a = a + 1
/// ```
///
/// Use instead:
/// ```python
/// a += 1
/// ```
///
/// ## Fix safety
/// This rule's fix is marked as being unsafe, in that it could alter semantics
/// of the given Python code in some scenarios.
///
/// For example, the following code using mutable data types as the assignment
/// target
/// ```python
/// a = [1]
/// b = a
/// a = a + [2]
/// assert (a, b) == ([1, 2], [1])
/// ```
///
/// is not the same as
/// ```python
/// a = [1]
/// b = a
/// a += [2]
/// assert (a, b) == ([1, 2], [1, 2])
/// ```
#[violation]
pub struct BinaryOpAndNormalAssignment;

impl Violation for BinaryOpAndNormalAssignment {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;

#[derive_message_formats]
fn message(&self) -> String {
format!(
"Normal assignment with left operand of binary operator being the same as the target."
)
}

fn fix_title(&self) -> Option<String> {
Some("Use augmented assignment instead.".to_string())
}
}

pub(crate) fn binary_op_and_normal_assignment(
checker: &mut Checker,
assign @ ast::StmtAssign { value, targets, .. }: &ast::StmtAssign,
) {
if targets.len() != 1 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using let-else here to make this check a bit nicer:

let target = Some(targets.first()) else {
    return;
}

Copy link
Contributor Author

@lshi18 lshi18 Feb 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please correct me if I am wrong, but do you mean

let Some(target) = target.first() else {
    return;
}

I agree that this looks nicer if it could be changed to it.

I don't think, in this case, that they are semantically equal. In particular, the following test case which is marked as OK fails after the suggested change.

a_number = index = a_number + 1 # OK

I think a further question would be whether we should detect the above case and, in the same gist, the one below.

index = a_number = a_number + 1 # OK

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad, I hadn't tested. Since targets is a vector, this should work:

let targets = vec![1];
// let targets = vec![1, 2];
// let targets: Vec<i32> = vec![];

let &[target] = targets.as_slice() else{
    return;
};

Copy link
Contributor Author

@lshi18 lshi18 Feb 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a quick test with pylint 3.0.3, whose R6104 rule detects this

a_number = index = a_number + 1 # OK

but not this,

index = a_number = a_number + 1 # OK

Do you think we should implement to reproduce this effect?

BTW, the test cases for this rule in the pylint project include neither of the above cases, so I think the current behaviour is a side-effect of implementation.

Meanwhile, my current implementation is incorrect regarding handling more complex subsript / attribute expressions. I'll reimplement it while also taking into consideration of your other suggestions .

return;
}
let target = targets.first().unwrap();

let rhs_expr = value
Copy link
Contributor

@sbrugman sbrugman Feb 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

 let (left_operand, operator, right_operand) = Some(
      value
       .as_bin_op_expr()
       .map(|e| (e.left.as_ref(), e.op, e.right.as_ref()))
      ) else {
       return;
}

See let

.as_bin_op_expr()
.map(|e| (e.left.as_ref(), e.op, e.right.as_ref()));
if rhs_expr.is_none() {
return;
}
let (left_operand, operator, right_operand) = rhs_expr.unwrap();

if name_expr(target, left_operand)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of testing these conditions, you could match the target/left_operand type with match.

There may be some other rules to serve as inspiration e.g.

fn is_constant(key: &Expr, names: &FxHashMap<&str, &ast::ExprName>) -> bool {

|| object_attribute_expr(target, left_operand)
|| index_subscript_expr(target, left_operand)
|| slice_subscript_expr(target, left_operand)
{
let diagnostic = Diagnostic::new(BinaryOpAndNormalAssignment, assign.range()).with_fix(
generate_fix(checker, target, operator, right_operand, assign.range()),
);
checker.diagnostics.push(diagnostic);
}
}

fn name_expr(target: &Expr, left_operand: &Expr) -> bool {
matches!(
(
target.as_name_expr(),
left_operand.as_name_expr()
),
(
Some(ast::ExprName {
id: target_name_id, ..
}),
Some(ast::ExprName {
id: left_name_id, ..
}),
) if target_name_id == left_name_id
)
}

fn object_attribute_expr(target: &Expr, left_operand: &Expr) -> bool {
matches!((
target
.as_attribute_expr()
.and_then(|attr| attr.value.as_name_expr())
.map(|name| &name.id),
target
.as_attribute_expr()
.map(|attr| attr.attr.as_str()),
left_operand
.as_attribute_expr()
.and_then(|attr| attr.value.as_name_expr())
.map(|name| &name.id),
left_operand
.as_attribute_expr()
.map(|attr| attr.attr.as_str())
),
(
Some(target_name_id),
Some(target_attr_id),
Some(left_name_id),
Some(left_attr_id)
)
if target_name_id == left_name_id && target_attr_id == left_attr_id
)
}

fn index_subscript_expr(target: &Expr, left_operand: &Expr) -> bool {
matches!((
target
.as_subscript_expr()
.and_then(|subs| subs.value.as_name_expr())
.map(|name| &name.id),
target
.as_subscript_expr()
.and_then(|subs| subs.slice.as_number_literal_expr())
.map(|num| &num.value),
left_operand
.as_subscript_expr()
.and_then(|subs| subs.value.as_name_expr())
.map(|name| &name.id),
left_operand
.as_subscript_expr()
.and_then(|subs| subs.slice.as_number_literal_expr())
.map(|num| &num.value),
),
(
Some(target_name),
Some(target_subs),
Some(left_name),
Some(left_subs)
)
if target_name == left_name && target_subs == left_subs
)
}

fn slice_subscript_expr(target: &Expr, left_operand: &Expr) -> bool {
match (
target
.as_subscript_expr()
.and_then(|subs| subs.value.as_name_expr())
.map(|name| &name.id),
target
.as_subscript_expr()
.and_then(|subs| subs.slice.as_slice_expr()),
left_operand
.as_subscript_expr()
.and_then(|subs| subs.value.as_name_expr())
.map(|name| &name.id),
left_operand
.as_subscript_expr()
.and_then(|subs| subs.slice.as_slice_expr()),
) {
(Some(target_name), Some(target_slice), Some(left_name), Some(left_slice))
if target_name == left_name =>
{
let target_lower = target_slice
.lower
.as_ref()
.and_then(|lower| lower.as_number_literal_expr())
.map(|num| &num.value);
let target_upper = target_slice
.upper
.as_ref()
.and_then(|upper| upper.as_number_literal_expr())
.map(|num| &num.value);
let left_lower = left_slice
.lower
.as_ref()
.and_then(|lower| lower.as_number_literal_expr())
.map(|num| &num.value);
let left_upper = left_slice
.upper
.as_ref()
.and_then(|upper| upper.as_number_literal_expr())
.map(|num| &num.value);

target_lower == left_lower && target_upper == left_upper
}
_ => false,
}
}

fn generate_fix(
checker: &Checker,
target: &Expr,
operator: ast::Operator,
right_operand: &Expr,
text_range: TextRange,
) -> Fix {
let new_stmt = ast::Stmt::AugAssign(StmtAugAssign {
range: TextRange::default(),
target: Box::new(target.clone()),
op: operator,
value: Box::new(right_operand.clone()),
});
let content = checker.generator().stmt(&new_stmt);
Fix::unsafe_edit(Edit::range_replacement(content, text_range))
}
2 changes: 2 additions & 0 deletions crates/ruff_linter/src/rules/pylint/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub(crate) use bad_str_strip_call::*;
pub(crate) use bad_string_format_character::BadStringFormatCharacter;
pub(crate) use bad_string_format_type::*;
pub(crate) use bidirectional_unicode::*;
pub(crate) use binary_op_and_normal_assignment::*;
pub(crate) use binary_op_exception::*;
pub(crate) use collapsible_else_if::*;
pub(crate) use compare_to_empty_string::*;
Expand Down Expand Up @@ -92,6 +93,7 @@ mod bad_str_strip_call;
pub(crate) mod bad_string_format_character;
mod bad_string_format_type;
mod bidirectional_unicode;
mod binary_op_and_normal_assignment;
mod binary_op_exception;
mod collapsible_else_if;
mod compare_to_empty_string;
Expand Down