Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Comment thread
ntBre marked this conversation as resolved.


# 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()
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub(crate) fn deferred_scopes(checker: &Checker) {
Rule::RuntimeImportInTypeCheckingBlock,
Rule::SingledispatchMethod,
Rule::SingledispatchmethodFunction,
Rule::SwapWithTemporaryVariable,
Rule::TooManyLocals,
Rule::TypingOnlyFirstPartyImport,
Rule::TypingOnlyStandardLibraryImport,
Expand Down Expand Up @@ -94,6 +95,10 @@ pub(crate) fn deferred_scopes(checker: &Checker) {
pylint::rules::global_variable_not_assigned(checker, scope);
}

if checker.is_rule_enabled(Rule::SwapWithTemporaryVariable) {
pylint::rules::swap_with_temporary_variable(checker, scope_id, scope);
}

if checker.is_rule_enabled(Rule::RedefinedArgumentFromLocal) {
pylint::rules::redefined_argument_from_local(checker, scope_id, scope);
}
Expand Down
1 change: 1 addition & 0 deletions crates/ruff_linter/src/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Pylint, "R1706") => rules::pylint::rules::AndOrTernary,
(Pylint, "R1708") => rules::pylint::rules::StopIterationReturn,
(Pylint, "R1711") => rules::pylint::rules::UselessReturn,
(Pylint, "R1712") => rules::pylint::rules::SwapWithTemporaryVariable,
(Pylint, "R1714") => rules::pylint::rules::RepeatedEqualityComparison,
(Pylint, "R1722") => rules::pylint::rules::SysExitAlias,
(Pylint, "R1730") => rules::pylint::rules::IfStmtMinMax,
Expand Down
4 changes: 4 additions & 0 deletions crates/ruff_linter/src/rules/pylint/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ mod tests {
#[test_case(Rule::CompareToEmptyString, Path::new("compare_to_empty_string.py"))]
#[test_case(Rule::ComparisonOfConstant, Path::new("comparison_of_constant.py"))]
#[test_case(Rule::ComparisonWithItself, Path::new("comparison_with_itself.py"))]
#[test_case(
Rule::SwapWithTemporaryVariable,
Path::new("swap_with_temporary_variable.py")
)]
#[test_case(Rule::EqWithoutHash, Path::new("eq_without_hash.py"))]
#[test_case(Rule::EmptyComment, Path::new("empty_comment.py"))]
#[test_case(Rule::EmptyComment, Path::new("empty_comment_line_continuation.py"))]
Expand Down
2 changes: 2 additions & 0 deletions crates/ruff_linter/src/rules/pylint/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ pub(crate) use stop_iteration_return::*;
pub(crate) use subprocess_popen_preexec_fn::*;
pub(crate) use subprocess_run_without_check::*;
pub(crate) use super_without_brackets::*;
pub(crate) use swap_with_temporary_variable::*;
pub(crate) use sys_exit_alias::*;
pub(crate) use too_many_arguments::*;
pub(crate) use too_many_boolean_expressions::*;
Expand Down Expand Up @@ -190,6 +191,7 @@ mod stop_iteration_return;
mod subprocess_popen_preexec_fn;
mod subprocess_run_without_check;
mod super_without_brackets;
mod swap_with_temporary_variable;
mod sys_exit_alias;
mod too_many_arguments;
mod too_many_boolean_expressions;
Expand Down
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
}
Loading
Loading