Skip to content

Commit 9f0567d

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

8 files changed

Lines changed: 225 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::ConsiderSwapVariables) {
20+
pylint::rules::consider_swap_variables(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::ConsiderSwapVariables,
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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ 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(Rule::ConsiderSwapVariables, Path::new("consider_swap_variables.py"))]
4950
#[test_case(Rule::EqWithoutHash, Path::new("eq_without_hash.py"))]
5051
#[test_case(Rule::EmptyComment, Path::new("empty_comment.py"))]
5152
#[test_case(Rule::EmptyComment, Path::new("empty_comment_line_continuation.py"))]
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
use ruff_diagnostics::{Applicability, Edit, Fix};
2+
use ruff_python_ast::Stmt;
3+
use ruff_python_ast::name::Name;
4+
use ruff_text_size::{Ranged, TextRange, TextSize};
5+
6+
use ruff_macros::{ViolationMetadata, derive_message_formats};
7+
8+
use crate::checkers::ast::Checker;
9+
use crate::{FixAvailability, Violation};
10+
11+
/// ## What it does
12+
/// Checks for code that swaps two variables using a temporary variable.
13+
///
14+
/// ## Why is this bad?
15+
/// 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.
16+
///
17+
/// ## Example
18+
/// ```python
19+
/// def function(x, y):
20+
/// if x > y:
21+
/// temp = x
22+
/// x = y
23+
/// y = temp
24+
/// assert x <= y
25+
/// ```
26+
///
27+
/// Use instead:
28+
/// ```python
29+
/// def function(x, y):
30+
/// if x > y:
31+
/// x, y = y, x
32+
/// assert x <= y
33+
/// ```
34+
///
35+
/// ## Fix safety
36+
/// The rule's fix is marked as safe, unless it contains comments. In this
37+
/// exception case, applying the quick fix would remove comments between the
38+
/// assignment statements.
39+
#[derive(ViolationMetadata)]
40+
#[violation_metadata(preview_since = "0.0.0")]
41+
pub(crate) struct ConsiderSwapVariables {
42+
var_a_name: Name,
43+
var_b_name: Name,
44+
}
45+
46+
#[derive(Eq, PartialEq, Debug, Clone)]
47+
struct VarToVarAssignment {
48+
target_var_name: Name,
49+
value_var_name: Name,
50+
start: TextSize,
51+
end: TextSize,
52+
}
53+
54+
impl Violation for ConsiderSwapVariables {
55+
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Always;
56+
57+
#[derive_message_formats]
58+
fn message(&self) -> String {
59+
format!(
60+
r#"Consider swapping `{first_var}` and `{second_var}` using "tuple unpacking": `{first_var}, {second_var} = {second_var}, {first_var}`"#,
61+
first_var = self.var_a_name,
62+
second_var = self.var_b_name
63+
)
64+
}
65+
66+
fn fix_title(&self) -> Option<String> {
67+
Some("Swap variables via tuple unpacking".to_string())
68+
}
69+
}
70+
71+
pub(crate) fn consider_swap_variables(checker: &Checker, stmts: &[Stmt]) {
72+
for stmt_sequence in stmts.windows(3) {
73+
let stmt_sequence = stmt_sequence
74+
.iter()
75+
.filter_map(var_to_var_assignment)
76+
.collect::<Vec<_>>();
77+
78+
// if unwrapping fails, one of the statements hasn't been a var to var assignment
79+
let [stmt_a, stmt_b, stmt_c] = stmt_sequence.as_slice() else {
80+
continue;
81+
};
82+
83+
// Detect patterns like:
84+
// temp = x
85+
// x = y
86+
// y = temp
87+
if stmt_a.value_var_name == stmt_b.target_var_name
88+
&& stmt_b.value_var_name == stmt_c.target_var_name
89+
&& stmt_a.target_var_name == stmt_c.value_var_name
90+
{
91+
let diagnostic = ConsiderSwapVariables {
92+
var_a_name: stmt_b.target_var_name.clone(),
93+
var_b_name: stmt_c.target_var_name.clone(),
94+
};
95+
let edit = Edit::replacement(
96+
format!(
97+
"{0}, {1} = {1}, {0}",
98+
&diagnostic.var_a_name, &diagnostic.var_b_name
99+
),
100+
stmt_a.start,
101+
stmt_c.end,
102+
);
103+
let mut diagnostic_guard =
104+
checker.report_diagnostic(diagnostic, TextRange::new(stmt_a.start, stmt_c.end));
105+
106+
// the quick fix would remove comments, hence it's unsafe
107+
let applicability = if checker.comment_ranges().intersects(edit.range()) {
108+
Applicability::Unsafe
109+
} else {
110+
Applicability::Safe
111+
};
112+
diagnostic_guard.set_fix(Fix::applicable_edit(edit, applicability));
113+
}
114+
}
115+
}
116+
117+
fn var_to_var_assignment(stmt: &Stmt) -> Option<VarToVarAssignment> {
118+
let (target, value) = match stmt {
119+
Stmt::Assign(stmt_assign) => {
120+
// only one variable is expected for matching the pattern
121+
let [target_variable] = stmt_assign.targets.as_slice() else {
122+
return None;
123+
};
124+
125+
(target_variable.clone(), stmt_assign.value.clone())
126+
}
127+
Stmt::AnnAssign(stmt_ann_assign) => {
128+
// only assignments that actually assign a value are relevant here
129+
let Some(value) = &stmt_ann_assign.value else {
130+
return None;
131+
};
132+
133+
((*stmt_ann_assign.target).clone(), value.clone())
134+
}
135+
// Stmt::AugAssign is not relevant because it modifies the content
136+
// of a variable based on its existing value, so it can't swap variables
137+
_ => return None,
138+
};
139+
140+
// assignment value is more complex than just a simple variable, skip such cases.
141+
if !target.is_name_expr() || !value.is_name_expr() {
142+
return None;
143+
}
144+
145+
Some(VarToVarAssignment {
146+
target_var_name: target.expect_name_expr().id,
147+
value_var_name: value.expect_name_expr().id,
148+
start: stmt.start(),
149+
end: stmt.end(),
150+
})
151+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub(crate) use collapsible_else_if::*;
1414
pub(crate) use compare_to_empty_string::*;
1515
pub(crate) use comparison_of_constant::*;
1616
pub(crate) use comparison_with_itself::*;
17+
pub(crate) use consider_swap_variables::*;
1718
pub(crate) use continue_in_finally::*;
1819
pub(crate) use dict_index_missing_items::*;
1920
pub(crate) use dict_iter_missing_items::*;
@@ -125,6 +126,7 @@ mod collapsible_else_if;
125126
mod compare_to_empty_string;
126127
mod comparison_of_constant;
127128
mod comparison_with_itself;
129+
mod consider_swap_variables;
128130
mod continue_in_finally;
129131
mod dict_index_missing_items;
130132
mod dict_iter_missing_items;
Lines changed: 46 additions & 0 deletions
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` using "tuple unpacking": `x, y = y, x`
5+
--> consider_swap_variables.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: Swap variables via tuple unpacking
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` using "tuple unpacking": `x, y = y, x`
26+
--> consider_swap_variables.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: Swap variables via tuple unpacking
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)