Skip to content

Commit 839b712

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

8 files changed

Lines changed: 377 additions & 0 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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

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: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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, TextSize};
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 it contains comments. In this
40+
/// exception case, applying the quick fix would remove comments between the
41+
/// assignment statements.
42+
#[derive(ViolationMetadata)]
43+
#[violation_metadata(preview_since = "NEXT_RUFF_VERSION")]
44+
pub(crate) struct SwapWithTemporaryVariable<'a> {
45+
first_var: &'a Name,
46+
second_var: &'a Name,
47+
}
48+
49+
impl Violation for SwapWithTemporaryVariable<'_> {
50+
const FIX_AVAILABILITY: crate::FixAvailability = crate::FixAvailability::Sometimes;
51+
52+
#[derive_message_formats]
53+
fn message(&self) -> String {
54+
let SwapWithTemporaryVariable {
55+
first_var,
56+
second_var,
57+
} = self;
58+
format!("Consider swapping `{first_var}` and `{second_var}` by using tuple unpacking")
59+
}
60+
61+
fn fix_title(&self) -> Option<String> {
62+
let SwapWithTemporaryVariable {
63+
first_var,
64+
second_var,
65+
} = self;
66+
67+
Some(format!(
68+
"Use `{first_var}, {second_var} = {second_var}, {first_var}` instead"
69+
))
70+
}
71+
}
72+
73+
pub(crate) fn swap_with_temporary_variable(checker: &Checker, scope_id: ScopeId, scope: &Scope) {
74+
let consecutive_assignments = scope.binding_ids().filter_map(|binding_id| {
75+
match_consecutive_assignments(checker.semantic(), scope_id, binding_id)
76+
});
77+
78+
for (stmt_a, stmt_b, stmt_c) in consecutive_assignments {
79+
// Detect patterns like:
80+
// temp = x
81+
// x = y
82+
// y = temp
83+
if stmt_a.value == stmt_b.target
84+
&& stmt_b.value == stmt_c.target
85+
&& stmt_a.target == stmt_c.value
86+
{
87+
// check whether there is any later read reference to the temporary variable -
88+
// in this case the automatic hotfix would result in broken code, because
89+
// this later read would attempt to read from a variable that no longer exists
90+
let is_variable_reused_later =
91+
is_variable_read_after(checker, &stmt_a, stmt_c.range.end());
92+
if is_variable_reused_later {
93+
continue;
94+
}
95+
96+
let diagnostic = SwapWithTemporaryVariable {
97+
first_var: stmt_b.target,
98+
second_var: stmt_c.target,
99+
};
100+
let edit_range = TextRange::new(stmt_a.range.start(), stmt_c.range.end());
101+
let edit = Edit::range_replacement(
102+
format!(
103+
"{0}, {1} = {1}, {0}",
104+
&diagnostic.first_var, &diagnostic.second_var
105+
),
106+
edit_range,
107+
);
108+
let mut diagnostic_guard = checker.report_diagnostic(diagnostic, edit_range);
109+
110+
// The quick fix would remove comments, hence it's unsafe if there are any comments in the relevant code part.
111+
let applicability = if checker.comment_ranges().intersects(edit.range()) {
112+
Applicability::Unsafe
113+
} else {
114+
Applicability::Safe
115+
};
116+
diagnostic_guard.set_fix(Fix::applicable_edit(edit, applicability));
117+
}
118+
}
119+
}
120+
121+
/// Match consecutive assignment statements.
122+
///
123+
/// Also see the `repeated_append` rule for a similar use case.
124+
fn match_consecutive_assignments<'a>(
125+
semantic: &'a SemanticModel<'a>,
126+
scope_id: ScopeId,
127+
binding_id: BindingId,
128+
) -> Option<(
129+
VarToVarAssignment<'a>,
130+
VarToVarAssignment<'a>,
131+
VarToVarAssignment<'a>,
132+
)> {
133+
let binding = semantic.binding(binding_id);
134+
135+
// Only consider simple assignments (no imports, function defs, etc.)
136+
if !binding.kind.is_assignment() {
137+
return None;
138+
}
139+
140+
let node_id = binding.source?;
141+
142+
let stmt = binding
143+
.statement(semantic)
144+
.expect("binding with source should have statement");
145+
146+
let stmt_a = VarToVarAssignment::from_stmt(stmt)?;
147+
148+
// Find the enclosing suite so we can look at the next siblings.
149+
// For the global scope, use the module body; otherwise, find the parent statement.
150+
let suite = if scope_id.is_global() {
151+
semantic
152+
.definitions
153+
.python_ast()
154+
.and_then(|module| traversal::EnclosingSuite::new(module, stmt.into()))
155+
} else {
156+
semantic
157+
.parent_statement(node_id)
158+
.and_then(|parent| traversal::suite(stmt, parent))
159+
}?;
160+
161+
let stmt_b = suite
162+
.next_sibling()
163+
.and_then(VarToVarAssignment::from_stmt)?;
164+
let stmt_c = suite
165+
.next_siblings()
166+
.get(1)
167+
.and_then(VarToVarAssignment::from_stmt)?;
168+
169+
Some((stmt_a, stmt_b, stmt_c))
170+
}
171+
172+
#[derive(Eq, PartialEq, Debug, Clone)]
173+
struct VarToVarAssignment<'a> {
174+
target: &'a Name,
175+
value: &'a Name,
176+
range: TextRange,
177+
}
178+
179+
impl<'a> VarToVarAssignment<'a> {
180+
fn from_stmt(stmt: &'a Stmt) -> Option<VarToVarAssignment<'a>> {
181+
let (target, value) = match stmt {
182+
Stmt::Assign(stmt_assign) => {
183+
// only one variable is expected for matching the pattern
184+
let [target_variable] = stmt_assign.targets.as_slice() else {
185+
return None;
186+
};
187+
188+
(target_variable, &stmt_assign.value)
189+
}
190+
Stmt::AnnAssign(stmt_ann_assign) => {
191+
// only assignments that actually assign a value are relevant here
192+
let Some(value) = &stmt_ann_assign.value else {
193+
return None;
194+
};
195+
196+
(&*stmt_ann_assign.target, value)
197+
}
198+
// Stmt::AugAssign is not relevant because it modifies the content
199+
// of a variable based on its existing value, so it can't swap variables
200+
_ => return None,
201+
};
202+
203+
// assignment value is more complex than just a simple variable, skip such cases.
204+
if let (Some(target_expr), Some(value_expr)) = (target.as_name_expr(), value.as_name_expr())
205+
{
206+
Some(Self {
207+
target: &target_expr.id,
208+
value: &value_expr.id,
209+
range: stmt.range(),
210+
})
211+
} else {
212+
None
213+
}
214+
}
215+
}
216+
217+
/// Check whether a variable is read after a given position.
218+
///
219+
/// Returns `true` if the variable assigned to in `variable_assignment` is read anywhere after `after_position`.
220+
fn is_variable_read_after(
221+
checker: &Checker,
222+
variable_assignment: &VarToVarAssignment,
223+
after_position: TextSize,
224+
) -> bool {
225+
// Get the variable binding for the variable assigned to in this statement,
226+
// e.g., in the example `a = b` this would be the binding to the variable `a`.
227+
let variable_binding = checker
228+
.semantic()
229+
.bindings
230+
.iter()
231+
.find(|binding| variable_assignment.range.contains_range(binding.range))
232+
.unwrap();
233+
234+
// If the variable is global (e.g., `global VARNAME`) or nonlocal (e.g., `nonlocal VARNAME`),
235+
// then it is intended to also be used elsewhere outside our scope and hence it's likely
236+
// to be used in other contexts as well.
237+
if variable_binding.is_global() || variable_binding.is_nonlocal() {
238+
return true;
239+
}
240+
241+
// Check if there's any read reference to the variable in the consecutive statements after
242+
// the provided `after_position`.
243+
variable_binding
244+
.references()
245+
.map(|reference| checker.semantic().reference(reference))
246+
.any(|other_reference| after_position < other_reference.start())
247+
}

0 commit comments

Comments
 (0)