Repository navigation
[pylint] Implement swap-with-temporary-variable (PLR1712)
#22205
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
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
62 changes: 62 additions & 0 deletions
62
crates/ruff_linter/resources/test/fixtures/pylint/swap_with_temporary_variable.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| # safe fix | ||
| def foo(x: int, y: int): | ||
| temp: int = x | ||
| x = y | ||
| y = temp | ||
|
|
||
|
|
||
| # safe fix inside an if condition | ||
| def foo(x: int, y: int): | ||
| if x > 5: | ||
| temp: int = x | ||
| x = y | ||
| y = temp | ||
|
|
||
|
|
||
| # unsafe fix because the swap statements contain a comment | ||
| def bar(x: int, y: int): | ||
| temp: int = x # comment | ||
| x = y | ||
| y = temp | ||
|
|
||
|
|
||
| # not a swap statement | ||
| def baz(x: int, y: int): | ||
| temp = x | ||
| x = y | ||
| y = x | ||
|
|
||
|
|
||
| # not a simple swap statement because temp variable is re-used later | ||
| def foobar(x: int, y: int): | ||
| temp = x | ||
| x = y | ||
| y = temp | ||
|
|
||
| # use temp variable again, | ||
| # so its declaration can't be removed | ||
| z = temp | ||
|
|
||
|
|
||
| # not a simple swap statement because the temp variable is global | ||
| swap_var = 0 | ||
|
|
||
|
|
||
| def quux(x: int, y: int): | ||
| global swap_var | ||
| swap_var = x | ||
| x = y | ||
| y = swap_var | ||
|
|
||
|
|
||
| # temp is read somewhere else in the code, so this is not a simple swap statement and hence ignored | ||
| def foo(x, y): | ||
| temp = [] | ||
|
|
||
| def bar(): | ||
| print(temp) | ||
|
|
||
| temp = x | ||
| x = y | ||
| y = temp | ||
| bar() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
224 changes: 224 additions & 0 deletions
224
crates/ruff_linter/src/rules/pylint/rules/swap_with_temporary_variable.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| use ruff_diagnostics::{Applicability, Edit, Fix}; | ||
| use ruff_python_ast::name::Name; | ||
| use ruff_python_ast::{Stmt, traversal}; | ||
| use ruff_python_semantic::{BindingId, Scope, ScopeId, SemanticModel}; | ||
| use ruff_text_size::{Ranged, TextRange}; | ||
|
|
||
| use ruff_macros::{ViolationMetadata, derive_message_formats}; | ||
|
|
||
| use crate::Violation; | ||
| use crate::checkers::ast::Checker; | ||
|
|
||
| /// ## What it does | ||
| /// Checks for code that swaps two variables using a temporary variable. | ||
| /// | ||
| /// ## Why is this bad? | ||
| /// Variables can be swapped by using tuple unpacking instead of using a | ||
| /// temporary variable. That also makes the intention of the swapping logic | ||
| /// more clear. | ||
| /// | ||
| /// ## Example | ||
| /// ```python | ||
| /// def function(x, y): | ||
| /// if x > y: | ||
| /// temp = x | ||
| /// x = y | ||
| /// y = temp | ||
| /// assert x <= y | ||
| /// ``` | ||
| /// | ||
| /// Use instead: | ||
| /// ```python | ||
| /// def function(x, y): | ||
| /// if x > y: | ||
| /// x, y = y, x | ||
| /// assert x <= y | ||
| /// ``` | ||
| /// | ||
| /// ## Fix safety | ||
| /// The rule's fix is marked as safe, unless the replacement range contains comments | ||
| /// that would be removed. | ||
| #[derive(ViolationMetadata)] | ||
| #[violation_metadata(preview_since = "NEXT_RUFF_VERSION")] | ||
| pub(crate) struct SwapWithTemporaryVariable<'a> { | ||
| first: &'a Name, | ||
| second: &'a Name, | ||
| } | ||
|
|
||
| impl Violation for SwapWithTemporaryVariable<'_> { | ||
| const FIX_AVAILABILITY: crate::FixAvailability = crate::FixAvailability::Sometimes; | ||
|
|
||
| #[derive_message_formats] | ||
| fn message(&self) -> String { | ||
| "Unnecessary temporary variable".to_string() | ||
| } | ||
|
|
||
| fn fix_title(&self) -> Option<String> { | ||
| let SwapWithTemporaryVariable { first, second } = self; | ||
|
|
||
| Some(format!( | ||
| "Use `{first}, {second} = {second}, {first}` instead" | ||
| )) | ||
| } | ||
| } | ||
|
|
||
| pub(crate) fn swap_with_temporary_variable(checker: &Checker, scope_id: ScopeId, scope: &Scope) { | ||
| let consecutive_assignments = scope.binding_ids().filter_map(|binding_id| { | ||
| match_consecutive_assignments(checker.semantic(), scope_id, binding_id) | ||
| }); | ||
|
|
||
| for (stmt_a, stmt_b, stmt_c) in consecutive_assignments { | ||
| // Detect patterns like: | ||
| // temp = x | ||
| // x = y | ||
| // y = temp | ||
| if stmt_a.value == stmt_b.target | ||
| && stmt_b.value == stmt_c.target | ||
| && stmt_a.target == stmt_c.value | ||
| { | ||
| // check whether there is any later read reference to the temporary variable - | ||
| // in this case the automatic hotfix would result in broken code, because | ||
| // this later read would attempt to read from a variable that no longer exists | ||
| let is_variable_reused_later = is_variable_read_after(checker, &stmt_a); | ||
| if is_variable_reused_later { | ||
| continue; | ||
| } | ||
|
|
||
| let first = stmt_b.target; | ||
| let second = stmt_c.target; | ||
| let edit_range = TextRange::new(stmt_a.start(), stmt_c.end()); | ||
| let edit = Edit::range_replacement( | ||
| format!("{first}, {second} = {second}, {first}"), | ||
| edit_range, | ||
| ); | ||
|
|
||
| // The quick fix would remove comments, hence it's unsafe if there are any comments in the relevant code part. | ||
| let applicability = if checker.comment_ranges().intersects(edit.range()) { | ||
| Applicability::Unsafe | ||
| } else { | ||
| Applicability::Safe | ||
| }; | ||
|
|
||
| checker | ||
| .report_diagnostic(SwapWithTemporaryVariable { first, second }, edit_range) | ||
| .set_fix(Fix::applicable_edit(edit, applicability)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Match consecutive assignment statements. | ||
| /// | ||
| /// Also see the `repeated_append` rule for a similar use case. | ||
| fn match_consecutive_assignments<'a>( | ||
| semantic: &'a SemanticModel<'a>, | ||
| scope_id: ScopeId, | ||
| binding_id: BindingId, | ||
| ) -> Option<( | ||
| VarToVarAssignment<'a>, | ||
| VarToVarAssignment<'a>, | ||
| VarToVarAssignment<'a>, | ||
| )> { | ||
| let binding = semantic.binding(binding_id); | ||
|
|
||
| // Only consider simple assignments (no imports, function defs, etc.) | ||
| if !binding.kind.is_assignment() { | ||
| return None; | ||
| } | ||
|
|
||
| let node_id = binding.source?; | ||
|
|
||
| let stmt = binding.statement(semantic)?; | ||
| let stmt_a = VarToVarAssignment::from_stmt(stmt)?; | ||
|
|
||
| // Find the enclosing suite so we can look at the next siblings. | ||
| // For the global scope, use the module body; otherwise, find the parent statement. | ||
| let suite = if scope_id.is_global() { | ||
| traversal::EnclosingSuite::new(semantic.definitions.python_ast()?, stmt.into()) | ||
| } else { | ||
| traversal::suite(stmt, semantic.parent_statement(node_id)?) | ||
| }?; | ||
|
|
||
| let stmt_b = VarToVarAssignment::from_stmt(suite.next_sibling()?)?; | ||
| let stmt_c = VarToVarAssignment::from_stmt(suite.next_siblings().get(1)?)?; | ||
|
|
||
| Some((stmt_a, stmt_b, stmt_c)) | ||
| } | ||
|
|
||
| #[derive(Eq, PartialEq, Debug, Clone)] | ||
| struct VarToVarAssignment<'a> { | ||
| target: &'a Name, | ||
| value: &'a Name, | ||
| range: TextRange, | ||
| } | ||
|
|
||
| impl Ranged for VarToVarAssignment<'_> { | ||
| fn range(&self) -> TextRange { | ||
| self.range | ||
| } | ||
| } | ||
|
|
||
| impl<'a> VarToVarAssignment<'a> { | ||
| fn from_stmt(stmt: &'a Stmt) -> Option<VarToVarAssignment<'a>> { | ||
| let (target, value) = match stmt { | ||
| Stmt::Assign(stmt_assign) => { | ||
| // only one variable is expected for matching the pattern | ||
| let [target_variable] = stmt_assign.targets.as_slice() else { | ||
| return None; | ||
| }; | ||
|
|
||
| (target_variable, &stmt_assign.value) | ||
| } | ||
| Stmt::AnnAssign(stmt_ann_assign) => { | ||
| // only assignments that actually assign a value are relevant here | ||
| let Some(value) = &stmt_ann_assign.value else { | ||
| return None; | ||
| }; | ||
|
|
||
| (&*stmt_ann_assign.target, value) | ||
| } | ||
| // Stmt::AugAssign is not relevant because it modifies the content | ||
| // of a variable based on its existing value, so it can't swap variables | ||
| _ => return None, | ||
| }; | ||
|
|
||
| // assignment value is more complex than just a simple variable, skip such cases. | ||
| if let (Some(target_expr), Some(value_expr)) = (target.as_name_expr(), value.as_name_expr()) | ||
| { | ||
| Some(Self { | ||
| target: &target_expr.id, | ||
| value: &value_expr.id, | ||
| range: stmt.range(), | ||
| }) | ||
| } else { | ||
| None | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Check whether a variable is read after a given position. | ||
| /// | ||
| /// Returns `true` if the variable assigned to in `variable_assignment` is read anywhere other than the swap statement. | ||
| fn is_variable_read_after(checker: &Checker, variable_assignment: &VarToVarAssignment) -> bool { | ||
| // Get the variable binding for the variable assigned to in this statement, | ||
| // e.g., in the example `a = b` this would be the binding to the variable `a`. | ||
| let Some(variable_binding) = checker | ||
| .semantic() | ||
| .bindings | ||
| .iter() | ||
| .find(|binding| variable_assignment.range.contains_range(binding.range)) | ||
| else { | ||
| return true; | ||
| }; | ||
|
|
||
| // If the variable is global (e.g., `global VARNAME`) or nonlocal (e.g., `nonlocal VARNAME`), | ||
| // then it is intended to also be used elsewhere outside our scope and hence it's likely | ||
| // to be used in other contexts as well. | ||
| if variable_binding.is_global() || variable_binding.is_nonlocal() { | ||
| return true; | ||
| } | ||
|
|
||
| // Check if there's any read reference to the variable other than the one from the swap statement | ||
| // We already confirmed that there is at least one reference (i.e. `y = temp`), so the variable is | ||
| // only re-used if there is any other reference than this one (i.e. reference count > 1). | ||
| variable_binding.references().count() > 1 | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.