Skip to content

Commit 7e79155

Browse files
committed
[pylint]: Implement consider-swap-variables (PLR1712)
1 parent d9fe996 commit 7e79155

8 files changed

Lines changed: 227 additions & 1 deletion

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# safe fix
2+
def foo(x: int, y: int):
3+
temp: int = x
4+
x = y
5+
y = temp
6+
7+
8+
# unsafe fix because the swap statements contain a comment
9+
def bar(x: int, y: int):
10+
temp: int = x # comment
11+
x = y
12+
y = temp
13+
14+
15+
# not a swap statement
16+
def baz(x: int, y: int):
17+
temp = x
18+
x = y
19+
y = x

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use ruff_python_ast::Stmt;
33
use crate::checkers::ast::Checker;
44
use crate::codes::Rule;
55
use crate::rules::refurb;
6-
use crate::rules::{flake8_pie, flake8_pyi};
6+
use crate::rules::{flake8_pie, flake8_pyi, pylint};
77

88
/// Run lint rules over a suite of [`Stmt`] syntax nodes.
99
pub(crate) fn suite(suite: &[Stmt], checker: &Checker) {
@@ -16,4 +16,7 @@ pub(crate) fn suite(suite: &[Stmt], checker: &Checker) {
1616
if checker.is_rule_enabled(Rule::RepeatedGlobal) {
1717
refurb::rules::repeated_global(checker, suite);
1818
}
19+
if checker.is_rule_enabled(Rule::SwapWithTemporaryVariable) {
20+
pylint::rules::swap_with_temporary_variable(checker, suite);
21+
}
1922
}

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: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
use itertools::Itertools;
2+
use ruff_diagnostics::{Applicability, Edit, Fix};
3+
use ruff_python_ast::Stmt;
4+
use ruff_python_ast::name::Name;
5+
use ruff_text_size::{Ranged, TextRange};
6+
7+
use ruff_macros::{ViolationMetadata, derive_message_formats};
8+
9+
use crate::AlwaysFixableViolation;
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 = "0.14.11")]
44+
pub(crate) struct SwapWithTemporaryVariable<'a> {
45+
first_var: &'a Name,
46+
second_var: &'a Name,
47+
}
48+
49+
#[derive(Eq, PartialEq, Debug, Clone)]
50+
struct VarToVarAssignment {
51+
target_var_name: Name,
52+
value_var_name: Name,
53+
range: TextRange,
54+
}
55+
56+
impl AlwaysFixableViolation for SwapWithTemporaryVariable<'_> {
57+
#[derive_message_formats]
58+
fn message(&self) -> String {
59+
let SwapWithTemporaryVariable {
60+
first_var,
61+
second_var,
62+
} = self;
63+
format!(r#"Consider swapping `{first_var}` and `{second_var}` by using tuple unpacking"#,)
64+
}
65+
66+
fn fix_title(&self) -> String {
67+
let SwapWithTemporaryVariable {
68+
first_var,
69+
second_var,
70+
} = self;
71+
format!("Use `{first_var}, {second_var} = {second_var}, {first_var}` instead")
72+
}
73+
}
74+
75+
pub(crate) fn swap_with_temporary_variable(checker: &Checker, stmts: &[Stmt]) {
76+
for stmt_sequence in stmts.iter().map(var_to_var_assignment).tuple_windows() {
77+
// if unwrapping fails, one of the statements hasn't been a var to var assignment
78+
let (Some(stmt_a), Some(stmt_b), Some(stmt_c)) = stmt_sequence else {
79+
continue;
80+
};
81+
82+
// Detect patterns like:
83+
// temp = x
84+
// x = y
85+
// y = temp
86+
if stmt_a.value_var_name == stmt_b.target_var_name
87+
&& stmt_b.value_var_name == stmt_c.target_var_name
88+
&& stmt_a.target_var_name == stmt_c.value_var_name
89+
{
90+
let diagnostic = SwapWithTemporaryVariable {
91+
first_var: &stmt_b.target_var_name,
92+
second_var: &stmt_c.target_var_name,
93+
};
94+
let edit_range = TextRange::new(stmt_a.range.start(), stmt_c.range.end());
95+
let edit = Edit::range_replacement(
96+
format!(
97+
"{0}, {1} = {1}, {0}",
98+
&diagnostic.first_var, &diagnostic.second_var
99+
),
100+
edit_range,
101+
);
102+
let mut diagnostic_guard = checker.report_diagnostic(diagnostic, edit_range);
103+
104+
// the quick fix would remove comments, hence it's unsafe
105+
let applicability = if checker.comment_ranges().intersects(edit.range()) {
106+
Applicability::Unsafe
107+
} else {
108+
Applicability::Safe
109+
};
110+
diagnostic_guard.set_fix(Fix::applicable_edit(edit, applicability));
111+
}
112+
}
113+
}
114+
115+
fn var_to_var_assignment(stmt: &Stmt) -> Option<VarToVarAssignment> {
116+
let (target, value) = match stmt {
117+
Stmt::Assign(stmt_assign) => {
118+
// only one variable is expected for matching the pattern
119+
let [target_variable] = stmt_assign.targets.as_slice() else {
120+
return None;
121+
};
122+
123+
(target_variable, &stmt_assign.value)
124+
}
125+
Stmt::AnnAssign(stmt_ann_assign) => {
126+
// only assignments that actually assign a value are relevant here
127+
let Some(value) = &stmt_ann_assign.value else {
128+
return None;
129+
};
130+
131+
(&*stmt_ann_assign.target, value)
132+
}
133+
// Stmt::AugAssign is not relevant because it modifies the content
134+
// of a variable based on its existing value, so it can't swap variables
135+
_ => return None,
136+
};
137+
138+
// assignment value is more complex than just a simple variable, skip such cases.
139+
if let (Some(target_expr), Some(value_expr)) =
140+
(target.clone().name_expr(), value.clone().name_expr())
141+
{
142+
Some(VarToVarAssignment {
143+
target_var_name: target_expr.id,
144+
value_var_name: value_expr.id,
145+
range: stmt.range(),
146+
})
147+
} else {
148+
None
149+
}
150+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
source: crates/ruff_linter/src/rules/pylint/mod.rs
3+
---
4+
PLR1712 [*] Consider swapping `x` and `y` by using tuple unpacking
5+
--> swap_with_temporary_variable.py:3:5
6+
|
7+
1 | # safe fix
8+
2 | def foo(x: int, y: int):
9+
3 | / temp: int = x
10+
4 | | x = y
11+
5 | | y = temp
12+
| |____________^
13+
|
14+
help: Use `x, y = y, x` instead
15+
1 | # safe fix
16+
2 | def foo(x: int, y: int):
17+
- temp: int = x
18+
- x = y
19+
- y = temp
20+
3 + x, y = y, x
21+
4 |
22+
5 |
23+
6 | # unsafe fix because the swap statements contain a comment
24+
25+
PLR1712 [*] Consider swapping `x` and `y` by using tuple unpacking
26+
--> swap_with_temporary_variable.py:10:5
27+
|
28+
8 | # unsafe fix because the swap statements contain a comment
29+
9 | def bar(x: int, y: int):
30+
10 | / temp: int = x # comment
31+
11 | | x = y
32+
12 | | y = temp
33+
| |____________^
34+
|
35+
help: Use `x, y = y, x` instead
36+
7 |
37+
8 | # unsafe fix because the swap statements contain a comment
38+
9 | def bar(x: int, y: int):
39+
- temp: int = x # comment
40+
- x = y
41+
- y = temp
42+
10 + x, y = y, x
43+
11 |
44+
12 |
45+
13 | # not a swap statement
46+
note: This is an unsafe fix and may change runtime behavior

ruff.schema.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)