Skip to content

Commit d022c96

Browse files
committed
[pylint]: Implement swap-with-temporary-variable (PLR1712)
1 parent d9fe996 commit d022c96

8 files changed

Lines changed: 367 additions & 0 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# safe fix
2+
def foo(x: int, y: int):
3+
temp: int = x
4+
x = y
5+
y = temp
6+
7+
8+
# safe fix inside an if condition
9+
def foo(x: int, y: int):
10+
if x > 5:
11+
temp: int = x
12+
x = y
13+
y = temp
14+
15+
16+
# unsafe fix because the swap statements contain a comment
17+
def bar(x: int, y: int):
18+
temp: int = x # comment
19+
x = y
20+
y = temp
21+
22+
23+
# not a swap statement
24+
def baz(x: int, y: int):
25+
temp = x
26+
x = y
27+
y = x
28+
29+
30+
# not a simple swap statement because temp variable is re-used later
31+
def foobar(x: int, y: int):
32+
temp = x
33+
x = y
34+
y = temp
35+
36+
# use temp variable again,
37+
# so its declaration can't be removed
38+
z = temp
39+
40+
41+
# not a simple swap statement because the temp variable is global
42+
swap_var = 0
43+
44+
45+
def quux(x: int, y: int):
46+
global swap_var
47+
swap_var = x
48+
x = y
49+
y = swap_var
50+
51+
52+
# temp is re-used later, so this is not a simple swap statement and hence ignored
53+
def foo(x, y):
54+
temp = []
55+
56+
def bar():
57+
print(temp)
58+
59+
temp = x
60+
x = y
61+
y = temp
62+
bar()

crates/ruff_linter/src/checkers/ast/analyze/deferred_scopes.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub(crate) fn deferred_scopes(checker: &Checker) {
2828
Rule::RuntimeImportInTypeCheckingBlock,
2929
Rule::SingledispatchMethod,
3030
Rule::SingledispatchmethodFunction,
31+
Rule::SwapWithTemporaryVariable,
3132
Rule::TooManyLocals,
3233
Rule::TypingOnlyFirstPartyImport,
3334
Rule::TypingOnlyStandardLibraryImport,
@@ -94,6 +95,10 @@ pub(crate) fn deferred_scopes(checker: &Checker) {
9495
pylint::rules::global_variable_not_assigned(checker, scope);
9596
}
9697

98+
if checker.is_rule_enabled(Rule::SwapWithTemporaryVariable) {
99+
pylint::rules::swap_with_temporary_variable(checker, scope_id, scope);
100+
}
101+
97102
if checker.is_rule_enabled(Rule::RedefinedArgumentFromLocal) {
98103
pylint::rules::redefined_argument_from_local(checker, scope_id, scope);
99104
}

crates/ruff_linter/src/codes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
288288
(Pylint, "R1706") => rules::pylint::rules::AndOrTernary,
289289
(Pylint, "R1708") => rules::pylint::rules::StopIterationReturn,
290290
(Pylint, "R1711") => rules::pylint::rules::UselessReturn,
291+
(Pylint, "R1712") => rules::pylint::rules::SwapWithTemporaryVariable,
291292
(Pylint, "R1714") => rules::pylint::rules::RepeatedEqualityComparison,
292293
(Pylint, "R1722") => rules::pylint::rules::SysExitAlias,
293294
(Pylint, "R1730") => rules::pylint::rules::IfStmtMinMax,

crates/ruff_linter/src/rules/pylint/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ mod tests {
4646
#[test_case(Rule::CompareToEmptyString, Path::new("compare_to_empty_string.py"))]
4747
#[test_case(Rule::ComparisonOfConstant, Path::new("comparison_of_constant.py"))]
4848
#[test_case(Rule::ComparisonWithItself, Path::new("comparison_with_itself.py"))]
49+
#[test_case(
50+
Rule::SwapWithTemporaryVariable,
51+
Path::new("swap_with_temporary_variable.py")
52+
)]
4953
#[test_case(Rule::EqWithoutHash, Path::new("eq_without_hash.py"))]
5054
#[test_case(Rule::EmptyComment, Path::new("empty_comment.py"))]
5155
#[test_case(Rule::EmptyComment, Path::new("empty_comment_line_continuation.py"))]

crates/ruff_linter/src/rules/pylint/rules/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ pub(crate) use stop_iteration_return::*;
7979
pub(crate) use subprocess_popen_preexec_fn::*;
8080
pub(crate) use subprocess_run_without_check::*;
8181
pub(crate) use super_without_brackets::*;
82+
pub(crate) use swap_with_temporary_variable::*;
8283
pub(crate) use sys_exit_alias::*;
8384
pub(crate) use too_many_arguments::*;
8485
pub(crate) use too_many_boolean_expressions::*;
@@ -190,6 +191,7 @@ mod stop_iteration_return;
190191
mod subprocess_popen_preexec_fn;
191192
mod subprocess_run_without_check;
192193
mod super_without_brackets;
194+
mod swap_with_temporary_variable;
193195
mod sys_exit_alias;
194196
mod too_many_arguments;
195197
mod too_many_boolean_expressions;
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
use ruff_diagnostics::{Applicability, Edit, Fix};
2+
use ruff_python_ast::name::Name;
3+
use ruff_python_ast::{Stmt, traversal};
4+
use ruff_python_semantic::{BindingId, Scope, ScopeId, SemanticModel};
5+
use ruff_text_size::{Ranged, TextRange};
6+
7+
use ruff_macros::{ViolationMetadata, derive_message_formats};
8+
9+
use crate::Violation;
10+
use crate::checkers::ast::Checker;
11+
12+
/// ## What it does
13+
/// Checks for code that swaps two variables using a temporary variable.
14+
///
15+
/// ## Why is this bad?
16+
/// Variables can be swapped by using tuple unpacking instead of using a
17+
/// temporary variable. That also makes the intention of the swapping logic
18+
/// more clear.
19+
///
20+
/// ## Example
21+
/// ```python
22+
/// def function(x, y):
23+
/// if x > y:
24+
/// temp = x
25+
/// x = y
26+
/// y = temp
27+
/// assert x <= y
28+
/// ```
29+
///
30+
/// Use instead:
31+
/// ```python
32+
/// def function(x, y):
33+
/// if x > y:
34+
/// x, y = y, x
35+
/// assert x <= y
36+
/// ```
37+
///
38+
/// ## Fix safety
39+
/// The rule's fix is marked as safe, unless the replacement range contains comments
40+
/// that would be removed.
41+
#[derive(ViolationMetadata)]
42+
#[violation_metadata(preview_since = "NEXT_RUFF_VERSION")]
43+
pub(crate) struct SwapWithTemporaryVariable<'a> {
44+
first: &'a Name,
45+
second: &'a Name,
46+
}
47+
48+
impl Violation for SwapWithTemporaryVariable<'_> {
49+
const FIX_AVAILABILITY: crate::FixAvailability = crate::FixAvailability::Sometimes;
50+
51+
#[derive_message_formats]
52+
fn message(&self) -> String {
53+
"Unnecessary temporary variable".to_string()
54+
}
55+
56+
fn fix_title(&self) -> Option<String> {
57+
let SwapWithTemporaryVariable { first, second } = self;
58+
59+
Some(format!(
60+
"Use `{first}, {second} = {second}, {first}` instead"
61+
))
62+
}
63+
}
64+
65+
pub(crate) fn swap_with_temporary_variable(checker: &Checker, scope_id: ScopeId, scope: &Scope) {
66+
let consecutive_assignments = scope.binding_ids().filter_map(|binding_id| {
67+
match_consecutive_assignments(checker.semantic(), scope_id, binding_id)
68+
});
69+
70+
for (stmt_a, stmt_b, stmt_c) in consecutive_assignments {
71+
// Detect patterns like:
72+
// temp = x
73+
// x = y
74+
// y = temp
75+
if stmt_a.value == stmt_b.target
76+
&& stmt_b.value == stmt_c.target
77+
&& stmt_a.target == stmt_c.value
78+
{
79+
// check whether there is any later read reference to the temporary variable -
80+
// in this case the automatic hotfix would result in broken code, because
81+
// this later read would attempt to read from a variable that no longer exists
82+
let is_variable_reused_later = is_variable_read_after(checker, &stmt_a);
83+
if is_variable_reused_later {
84+
continue;
85+
}
86+
87+
let first = stmt_b.target;
88+
let second = stmt_c.target;
89+
let edit_range = TextRange::new(stmt_a.start(), stmt_c.end());
90+
let edit = Edit::range_replacement(
91+
format!("{first}, {second} = {second}, {first}"),
92+
edit_range,
93+
);
94+
95+
// The quick fix would remove comments, hence it's unsafe if there are any comments in the relevant code part.
96+
let applicability = if checker.comment_ranges().intersects(edit.range()) {
97+
Applicability::Unsafe
98+
} else {
99+
Applicability::Safe
100+
};
101+
102+
checker
103+
.report_diagnostic(SwapWithTemporaryVariable { first, second }, edit_range)
104+
.set_fix(Fix::applicable_edit(edit, applicability));
105+
}
106+
}
107+
}
108+
109+
/// Match consecutive assignment statements.
110+
///
111+
/// Also see the `repeated_append` rule for a similar use case.
112+
fn match_consecutive_assignments<'a>(
113+
semantic: &'a SemanticModel<'a>,
114+
scope_id: ScopeId,
115+
binding_id: BindingId,
116+
) -> Option<(
117+
VarToVarAssignment<'a>,
118+
VarToVarAssignment<'a>,
119+
VarToVarAssignment<'a>,
120+
)> {
121+
let binding = semantic.binding(binding_id);
122+
123+
// Only consider simple assignments (no imports, function defs, etc.)
124+
if !binding.kind.is_assignment() {
125+
return None;
126+
}
127+
128+
let node_id = binding.source?;
129+
130+
let stmt = binding.statement(semantic)?;
131+
let stmt_a = VarToVarAssignment::from_stmt(stmt)?;
132+
133+
// Find the enclosing suite so we can look at the next siblings.
134+
// For the global scope, use the module body; otherwise, find the parent statement.
135+
let suite = if scope_id.is_global() {
136+
traversal::EnclosingSuite::new(semantic.definitions.python_ast()?, stmt.into())
137+
} else {
138+
traversal::suite(stmt, semantic.parent_statement(node_id)?)
139+
}?;
140+
141+
let stmt_b = VarToVarAssignment::from_stmt(suite.next_sibling()?)?;
142+
let stmt_c = VarToVarAssignment::from_stmt(suite.next_siblings().get(1)?)?;
143+
144+
Some((stmt_a, stmt_b, stmt_c))
145+
}
146+
147+
#[derive(Eq, PartialEq, Debug, Clone)]
148+
struct VarToVarAssignment<'a> {
149+
target: &'a Name,
150+
value: &'a Name,
151+
range: TextRange,
152+
}
153+
154+
impl Ranged for VarToVarAssignment<'_> {
155+
fn range(&self) -> TextRange {
156+
self.range
157+
}
158+
}
159+
160+
impl<'a> VarToVarAssignment<'a> {
161+
fn from_stmt(stmt: &'a Stmt) -> Option<VarToVarAssignment<'a>> {
162+
let (target, value) = match stmt {
163+
Stmt::Assign(stmt_assign) => {
164+
// only one variable is expected for matching the pattern
165+
let [target_variable] = stmt_assign.targets.as_slice() else {
166+
return None;
167+
};
168+
169+
(target_variable, &stmt_assign.value)
170+
}
171+
Stmt::AnnAssign(stmt_ann_assign) => {
172+
// only assignments that actually assign a value are relevant here
173+
let Some(value) = &stmt_ann_assign.value else {
174+
return None;
175+
};
176+
177+
(&*stmt_ann_assign.target, value)
178+
}
179+
// Stmt::AugAssign is not relevant because it modifies the content
180+
// of a variable based on its existing value, so it can't swap variables
181+
_ => return None,
182+
};
183+
184+
// assignment value is more complex than just a simple variable, skip such cases.
185+
if let (Some(target_expr), Some(value_expr)) = (target.as_name_expr(), value.as_name_expr())
186+
{
187+
Some(Self {
188+
target: &target_expr.id,
189+
value: &value_expr.id,
190+
range: stmt.range(),
191+
})
192+
} else {
193+
None
194+
}
195+
}
196+
}
197+
198+
/// Check whether a variable is read after a given position.
199+
///
200+
/// Returns `true` if the variable assigned to in `variable_assignment` is read anywhere other than the swap statement.
201+
fn is_variable_read_after(checker: &Checker, variable_assignment: &VarToVarAssignment) -> bool {
202+
// Get the variable binding for the variable assigned to in this statement,
203+
// e.g., in the example `a = b` this would be the binding to the variable `a`.
204+
let Some(variable_binding) = checker
205+
.semantic()
206+
.bindings
207+
.iter()
208+
.find(|binding| variable_assignment.range.contains_range(binding.range))
209+
else {
210+
return true;
211+
};
212+
213+
// If the variable is global (e.g., `global VARNAME`) or nonlocal (e.g., `nonlocal VARNAME`),
214+
// then it is intended to also be used elsewhere outside our scope and hence it's likely
215+
// to be used in other contexts as well.
216+
if variable_binding.is_global() || variable_binding.is_nonlocal() {
217+
return true;
218+
}
219+
220+
// Check if there's any read reference to the variable other than the one from the swap statement
221+
// We already confirmed that there is at least one reference (i.e. `y = temp`), so the variable is
222+
// only re-used if there is any other reference than this one (i.e. reference count > 1).
223+
variable_binding.references().count() > 1
224+
}

0 commit comments

Comments
 (0)