From 2af839c76ea17a071d573cf8798ba0ec4d663ff3 Mon Sep 17 00:00:00 2001 From: seroperson Date: Sun, 22 Mar 2026 22:49:36 +0300 Subject: [PATCH 1/2] Implement unnecessary-if (RUF050) --- .../resources/test/fixtures/ruff/RUF050.py | 124 +++++++ .../test/fixtures/ruff/RUF050_F401.py | 18 + .../test/fixtures/ruff/RUF050_RUF047.py | 31 ++ .../src/checkers/ast/analyze/statement.rs | 3 + crates/ruff_linter/src/codes.rs | 1 + crates/ruff_linter/src/rules/ruff/mod.rs | 42 +++ .../ruff_linter/src/rules/ruff/rules/mod.rs | 2 + .../src/rules/ruff/rules/unnecessary_if.rs | 181 ++++++++++ ..._rules__ruff__tests__RUF050_RUF050.py.snap | 330 ++++++++++++++++++ ...s__unnecessary_if_and_needless_else-2.snap | 26 ++ ...sts__unnecessary_if_and_needless_else.snap | 63 ++++ ...s__unnecessary_if_and_unused_import-2.snap | 17 + ...sts__unnecessary_if_and_unused_import.snap | 41 +++ ruff.schema.json | 1 + 14 files changed, 880 insertions(+) create mode 100644 crates/ruff_linter/resources/test/fixtures/ruff/RUF050.py create mode 100644 crates/ruff_linter/resources/test/fixtures/ruff/RUF050_F401.py create mode 100644 crates/ruff_linter/resources/test/fixtures/ruff/RUF050_RUF047.py create mode 100644 crates/ruff_linter/src/rules/ruff/rules/unnecessary_if.rs create mode 100644 crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF050_RUF050.py.snap create mode 100644 crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_needless_else-2.snap create mode 100644 crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_needless_else.snap create mode 100644 crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_unused_import-2.snap create mode 100644 crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_unused_import.snap diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF050.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF050.py new file mode 100644 index 0000000000000..b49f2e11926a7 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF050.py @@ -0,0 +1,124 @@ +### Errors (condition removed entirely) + +# Simple if with pass +if True: + pass + +# Simple if with ellipsis +if True: + ... + +# Side-effect-free condition (comparison) +import sys +if sys.version_info >= (3, 11): + pass + +# Side-effect-free condition (boolean operator) +if x and y: + pass + +# Nested in function +def nested(): + if a: + pass + +# Single-line form (pass) +if True: pass + +# Single-line form (ellipsis) +if True: ... + +# Multiple pass statements +if True: + pass + pass + +# Mixed pass and ellipsis +if True: + pass + ... + +# Only statement in a with block +with pytest.raises(ValueError, match=msg): + if obj1: + pass + + +### Errors (condition preserved as expression statement) + +# Function call +if foo(): + pass + +# Method call +if bar.baz(): + pass + +# Nested call in boolean operator +if x and foo(): + pass + +# Walrus operator with call +if (x := foo()): + pass + +# Walrus operator without call +if (x := y): + pass + +# Only statement in a suite +class Foo: + if foo(): + pass + + +### No errors + +# Non-empty body +if True: + bar() + +# Body with non-stub statement +if True: + pass + foo() + +# Has elif clause (handled by RUF047) +if True: + pass +elif True: + pass + +# Has else clause (handled by RUF047) +if True: + pass +else: + pass + +# TYPE_CHECKING block (handled by TC005) +from typing import TYPE_CHECKING +if TYPE_CHECKING: + pass + +# Comment in body +if True: + # comment + pass + +# Inline comment after pass +if True: + pass # comment + +# Inline comment on if line +if True: # comment + pass + +# Trailing comment at body indentation +if True: + pass + # trailing comment + +# Trailing comment deeper than body indentation +if True: + pass + # deeper trailing comment diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF050_F401.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF050_F401.py new file mode 100644 index 0000000000000..62b397c6cd807 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF050_F401.py @@ -0,0 +1,18 @@ +# Reproduces the scenario from issue #9472: +# F401 removes unused imports leaving empty `if` blocks, +# RUF050 removes those blocks, then F401 cleans up the +# now-unused guard imports on subsequent fix iterations. + +import os +import sys + +# F401 removes the unused `ExceptionGroup` import, leaving `pass`. +# Then RUF050 removes the empty `if`, and F401 removes `sys`. +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + +# Already-empty block handled in a single pass by RUF050 +if sys.version_info < (3, 11): + pass + +print(os.getcwd()) diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF050_RUF047.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF050_RUF047.py new file mode 100644 index 0000000000000..fcce5c43e52f2 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF050_RUF047.py @@ -0,0 +1,31 @@ +### Errors (both RUF047 and RUF050 converge) + +# RUF047 removes the else, then RUF050 removes the remaining if. +if sys.version_info >= (3, 11): + pass +else: + pass + +# Same with elif. +if sys.version_info >= (3, 11): + pass +elif sys.version_info >= (3, 10): + pass +else: + pass + +# Side-effect in condition: RUF047 removes the else, then RUF050 +# replaces the remaining `if` with the condition expression +if foo(): + pass +else: + pass + + +### No errors + +# Non-empty if body: neither rule fires. +if sys.version_info >= (3, 11): + bar() +else: + baz() diff --git a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs index 4031022200d2f..fd673e5436468 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs @@ -1122,6 +1122,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) { if checker.is_rule_enabled(Rule::NeedlessElse) { ruff::rules::needless_else(checker, if_.into()); } + if checker.is_rule_enabled(Rule::UnnecessaryIf) { + ruff::rules::unnecessary_if(checker, if_); + } } Stmt::Assert( assert_stmt @ ast::StmtAssert { diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 0559059f79e64..17f2cbbdae82d 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -1049,6 +1049,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Ruff, "047") => rules::ruff::rules::NeedlessElse, (Ruff, "048") => rules::ruff::rules::MapIntVersionParsing, (Ruff, "049") => rules::ruff::rules::DataclassEnum, + (Ruff, "050") => rules::ruff::rules::UnnecessaryIf, (Ruff, "051") => rules::ruff::rules::IfKeyInDictDel, (Ruff, "052") => rules::ruff::rules::UsedDummyVariable, (Ruff, "053") => rules::ruff::rules::ClassWithMixedTypeVars, diff --git a/crates/ruff_linter/src/rules/ruff/mod.rs b/crates/ruff_linter/src/rules/ruff/mod.rs index 78ae80e1026ed..d6c2de86faba9 100644 --- a/crates/ruff_linter/src/rules/ruff/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/mod.rs @@ -98,6 +98,7 @@ mod tests { #[test_case(Rule::MapIntVersionParsing, Path::new("RUF048.py"))] #[test_case(Rule::MapIntVersionParsing, Path::new("RUF048_1.py"))] #[test_case(Rule::DataclassEnum, Path::new("RUF049.py"))] + #[test_case(Rule::UnnecessaryIf, Path::new("RUF050.py"))] #[test_case(Rule::IfKeyInDictDel, Path::new("RUF051.py"))] #[test_case(Rule::UsedDummyVariable, Path::new("RUF052_0.py"))] #[test_case(Rule::UsedDummyVariable, Path::new("RUF052_1.py"))] @@ -199,6 +200,47 @@ mod tests { Ok(()) } + /// Test that RUF047 (needless-else) and RUF050 (unnecessary-if) converge + /// when both are enabled: RUF047 removes the empty `else` first, then + /// RUF050 removes the remaining empty `if` on the next fix iteration. + #[test] + fn unnecessary_if_and_needless_else() -> Result<()> { + use ruff_python_ast::{PySourceType, SourceType}; + + let path = test_resource_path("fixtures").join("ruff/RUF050_RUF047.py"); + let source_type = SourceType::Python(PySourceType::from(&path)); + let source_kind = SourceKind::from_path(&path, source_type)?.expect("valid source"); + let settings = + settings::LinterSettings::for_rules(vec![Rule::NeedlessElse, Rule::UnnecessaryIf]); + + let (diagnostics, transformed) = test_contents(&source_kind, &path, &settings); + assert_diagnostics!(diagnostics); + + insta::assert_snapshot!(transformed.source_code()); + Ok(()) + } + + /// Reproduces issue #9472: F401 removes unused imports leaving empty `if` + /// blocks, then RUF050 removes those, then F401 cleans up the now-unused + /// guard imports. Verifies the full chain converges and produces the + /// expected output. + #[test] + fn unnecessary_if_and_unused_import() -> Result<()> { + use ruff_python_ast::{PySourceType, SourceType}; + + let path = test_resource_path("fixtures").join("ruff/RUF050_F401.py"); + let source_type = SourceType::Python(PySourceType::from(&path)); + let source_kind = SourceKind::from_path(&path, source_type)?.expect("valid source"); + let settings = + settings::LinterSettings::for_rules(vec![Rule::UnusedImport, Rule::UnnecessaryIf]); + + let (diagnostics, transformed) = test_contents(&source_kind, &path, &settings); + assert_diagnostics!(diagnostics); + + insta::assert_snapshot!(transformed.source_code()); + Ok(()) + } + #[test] fn missing_fstring_syntax_backslash_py311() -> Result<()> { assert_diagnostics_diff!( diff --git a/crates/ruff_linter/src/rules/ruff/rules/mod.rs b/crates/ruff_linter/src/rules/ruff/rules/mod.rs index dfa6d0900bb06..33e3ffe6d1102 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mod.rs @@ -54,6 +54,7 @@ pub(crate) use test_rules::*; pub(crate) use unmatched_suppression_comment::*; pub(crate) use unnecessary_assign_before_yield::*; pub(crate) use unnecessary_cast_to_int::*; +pub(crate) use unnecessary_if::*; pub(crate) use unnecessary_iterable_allocation_for_first_element::*; pub(crate) use unnecessary_key_check::*; pub(crate) use unnecessary_literal_within_deque_call::*; @@ -129,6 +130,7 @@ pub(crate) mod test_rules; mod unmatched_suppression_comment; mod unnecessary_assign_before_yield; mod unnecessary_cast_to_int; +mod unnecessary_if; mod unnecessary_iterable_allocation_for_first_element; mod unnecessary_key_check; mod unnecessary_literal_within_deque_call; diff --git a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_if.rs b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_if.rs new file mode 100644 index 0000000000000..6453c4d59270f --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_if.rs @@ -0,0 +1,181 @@ +use std::cmp::Ordering; + +use ruff_macros::{ViolationMetadata, derive_message_formats}; +use ruff_python_ast::helpers::{ + any_over_expr, comment_indentation_after, contains_effect, is_stub_body, +}; +use ruff_python_ast::token::TokenKind; +use ruff_python_ast::whitespace::indentation; +use ruff_python_ast::{Expr, StmtIf}; +use ruff_python_semantic::analyze::typing; +use ruff_source_file::LineRanges; +use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; + +use crate::checkers::ast::Checker; +use crate::{AlwaysFixableViolation, Edit, Fix, fix}; + +/// ## What it does +/// Checks for `if` statements (without `elif` or `else` branches) where the +/// body contains only `pass` or `...` +/// +/// ## Why is this bad? +/// An `if` statement with an empty body either does nothing (when the +/// condition is side-effect-free) or could be replaced with just the +/// condition expression (when it has side effects). This pattern commonly +/// arises when auto-fixers remove unused imports from conditional blocks +/// (e.g., version-dependent imports), leaving behind an empty skeleton. +/// +/// ## Example +/// ```python +/// import sys +/// +/// if sys.version_info >= (3, 11): +/// pass +/// ``` +/// +/// Use instead: +/// ```python +/// import sys +/// ``` +/// +/// ## Fix safety +/// When the condition is side-effect-free, the fix removes the entire `if` +/// statement. +/// +/// When the condition has side effects (e.g., a function call), the fix +/// replaces the `if` statement with just the condition as an expression +/// statement, preserving the side effects. +/// +/// ## Related rules +/// - [`needless-else (RUF047)`]: Detects empty `else` clauses. For `if`/`else` +/// statements where all branches are empty, `RUF047` first removes the empty +/// `else`, and then this rule catches the remaining empty `if`. +/// - [`empty-type-checking-block (TC005)`]: Detects empty `if TYPE_CHECKING` +/// blocks specifically. +#[derive(ViolationMetadata)] +#[violation_metadata(preview_since = "NEXT_RUFF_VERSION")] +pub(crate) struct UnnecessaryIf; + +impl AlwaysFixableViolation for UnnecessaryIf { + #[derive_message_formats] + fn message(&self) -> String { + "Empty `if` statement".to_string() + } + + fn fix_title(&self) -> String { + "Remove the `if` statement".to_string() + } +} + +/// RUF050 +pub(crate) fn unnecessary_if(checker: &Checker, stmt: &StmtIf) { + let StmtIf { + test, + body, + elif_else_clauses, + .. + } = stmt; + + // Only handle bare `if` blocks — `elif`/`else` branches are handled by + // RUF047 (needless-else) + if !elif_else_clauses.is_empty() { + return; + } + + if !is_stub_body(body) { + return; + } + + // Skip `if TYPE_CHECKING` blocks — handled by TC005 + if typing::is_type_checking_block(stmt, checker.semantic()) { + return; + } + + // Skip if the body contains a comment + if if_contains_comments(stmt, checker) { + return; + } + + let has_side_effects = contains_effect(test, |id| checker.semantic().has_builtin_binding(id)) + || any_over_expr(test, &|expr| matches!(expr, Expr::Named(_))); + + let mut diagnostic = checker.report_diagnostic(UnnecessaryIf, stmt.range()); + + if has_side_effects { + // Replace `if cond: pass` with `cond` as an expression statement. + // Walrus operators need parentheses to be valid as statements. + let condition_text = checker.locator().slice(test.range()); + let replacement = if test.is_named_expr() { + format!("({condition_text})") + } else { + condition_text.to_string() + }; + let edit = Edit::range_replacement(replacement, stmt.range()); + diagnostic.set_fix(Fix::safe_edit(edit)); + } else { + let stmt_ref = checker.semantic().current_statement(); + let parent = checker.semantic().current_statement_parent(); + let edit = fix::edits::delete_stmt(stmt_ref, parent, checker.locator(), checker.indexer()); + let fix = Fix::safe_edit(edit).isolate(Checker::isolation( + checker.semantic().current_statement_parent_id(), + )); + diagnostic.set_fix(fix); + } +} + +/// Returns `true` if the `if` statement contains a comment +fn if_contains_comments(stmt: &StmtIf, checker: &Checker) -> bool { + let source = checker.source(); + + // Use `line_end` (before the newline) instead of `full_line_end` (after + // the newline) to avoid touching the range of a comment on the next line. + // `TextRange::intersect` considers touching ranges as intersecting. + let stmt_line_end = source.line_end(stmt.end()); + let check_range = TextRange::new(stmt.start(), stmt_line_end); + + let Some(last_stmt) = stmt.body.last() else { + return false; + }; + + let stmt_full_end = source.full_line_end(stmt.end()); + + checker.comment_ranges().intersects(check_range) + || if_has_trailing_comment(stmt, last_stmt, stmt_full_end, checker) +} + +/// Returns `true` if the `if` branch has a trailing own-line comment +fn if_has_trailing_comment( + stmt: &StmtIf, + last_body_stmt: &ruff_python_ast::Stmt, + stmt_full_end: TextSize, + checker: &Checker, +) -> bool { + let (tokens, source) = (checker.tokens(), checker.source()); + + // Compare against the `if` keyword indentation rather than the body + // statement — handles single-line forms like `if True: pass` + let stmt_indentation = indentation(source, stmt).unwrap_or_default().text_len(); + + for token in tokens.after(stmt_full_end) { + match token.kind() { + TokenKind::Comment => { + let comment_indentation = + comment_indentation_after(last_body_stmt.into(), token.range(), source); + + match comment_indentation.cmp(&stmt_indentation) { + Ordering::Greater => return true, + Ordering::Equal | Ordering::Less => break, + } + } + + TokenKind::NonLogicalNewline + | TokenKind::Newline + | TokenKind::Indent + | TokenKind::Dedent => {} + + _ => break, + } + } + + false +} diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF050_RUF050.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF050_RUF050.py.snap new file mode 100644 index 0000000000000..04d46ec7dceb2 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF050_RUF050.py.snap @@ -0,0 +1,330 @@ +--- +source: crates/ruff_linter/src/rules/ruff/mod.rs +--- +RUF050 [*] Empty `if` statement + --> RUF050.py:4:1 + | +3 | # Simple if with pass +4 | / if True: +5 | | pass + | |________^ +6 | +7 | # Simple if with ellipsis + | +help: Remove the `if` statement +1 | ### Errors (condition removed entirely) +2 | +3 | # Simple if with pass + - if True: + - pass +4 | +5 | # Simple if with ellipsis +6 | if True: + +RUF050 [*] Empty `if` statement + --> RUF050.py:8:1 + | + 7 | # Simple if with ellipsis + 8 | / if True: + 9 | | ... + | |_______^ +10 | +11 | # Side-effect-free condition (comparison) + | +help: Remove the `if` statement +5 | pass +6 | +7 | # Simple if with ellipsis + - if True: + - ... +8 | +9 | # Side-effect-free condition (comparison) +10 | import sys + +RUF050 [*] Empty `if` statement + --> RUF050.py:13:1 + | +11 | # Side-effect-free condition (comparison) +12 | import sys +13 | / if sys.version_info >= (3, 11): +14 | | pass + | |________^ +15 | +16 | # Side-effect-free condition (boolean operator) + | +help: Remove the `if` statement +10 | +11 | # Side-effect-free condition (comparison) +12 | import sys + - if sys.version_info >= (3, 11): + - pass +13 | +14 | # Side-effect-free condition (boolean operator) +15 | if x and y: + +RUF050 [*] Empty `if` statement + --> RUF050.py:17:1 + | +16 | # Side-effect-free condition (boolean operator) +17 | / if x and y: +18 | | pass + | |________^ +19 | +20 | # Nested in function + | +help: Remove the `if` statement +14 | pass +15 | +16 | # Side-effect-free condition (boolean operator) + - if x and y: + - pass +17 | +18 | # Nested in function +19 | def nested(): + +RUF050 [*] Empty `if` statement + --> RUF050.py:22:5 + | +20 | # Nested in function +21 | def nested(): +22 | / if a: +23 | | pass + | |____________^ +24 | +25 | # Single-line form (pass) + | +help: Remove the `if` statement +19 | +20 | # Nested in function +21 | def nested(): + - if a: + - pass +22 + pass +23 | +24 | # Single-line form (pass) +25 | if True: pass + +RUF050 [*] Empty `if` statement + --> RUF050.py:26:1 + | +25 | # Single-line form (pass) +26 | if True: pass + | ^^^^^^^^^^^^^ +27 | +28 | # Single-line form (ellipsis) + | +help: Remove the `if` statement +23 | pass +24 | +25 | # Single-line form (pass) + - if True: pass +26 | +27 | # Single-line form (ellipsis) +28 | if True: ... + +RUF050 [*] Empty `if` statement + --> RUF050.py:29:1 + | +28 | # Single-line form (ellipsis) +29 | if True: ... + | ^^^^^^^^^^^^ +30 | +31 | # Multiple pass statements + | +help: Remove the `if` statement +26 | if True: pass +27 | +28 | # Single-line form (ellipsis) + - if True: ... +29 | +30 | # Multiple pass statements +31 | if True: + +RUF050 [*] Empty `if` statement + --> RUF050.py:32:1 + | +31 | # Multiple pass statements +32 | / if True: +33 | | pass +34 | | pass + | |________^ +35 | +36 | # Mixed pass and ellipsis + | +help: Remove the `if` statement +29 | if True: ... +30 | +31 | # Multiple pass statements + - if True: + - pass + - pass +32 | +33 | # Mixed pass and ellipsis +34 | if True: + +RUF050 [*] Empty `if` statement + --> RUF050.py:37:1 + | +36 | # Mixed pass and ellipsis +37 | / if True: +38 | | pass +39 | | ... + | |_______^ +40 | +41 | # Only statement in a with block + | +help: Remove the `if` statement +34 | pass +35 | +36 | # Mixed pass and ellipsis + - if True: + - pass + - ... +37 | +38 | # Only statement in a with block +39 | with pytest.raises(ValueError, match=msg): + +RUF050 [*] Empty `if` statement + --> RUF050.py:43:5 + | +41 | # Only statement in a with block +42 | with pytest.raises(ValueError, match=msg): +43 | / if obj1: +44 | | pass + | |____________^ + | +help: Remove the `if` statement +40 | +41 | # Only statement in a with block +42 | with pytest.raises(ValueError, match=msg): + - if obj1: + - pass +43 + pass +44 | +45 | +46 | ### Errors (condition preserved as expression statement) + +RUF050 [*] Empty `if` statement + --> RUF050.py:50:1 + | +49 | # Function call +50 | / if foo(): +51 | | pass + | |________^ +52 | +53 | # Method call + | +help: Remove the `if` statement +47 | ### Errors (condition preserved as expression statement) +48 | +49 | # Function call + - if foo(): + - pass +50 + foo() +51 | +52 | # Method call +53 | if bar.baz(): + +RUF050 [*] Empty `if` statement + --> RUF050.py:54:1 + | +53 | # Method call +54 | / if bar.baz(): +55 | | pass + | |________^ +56 | +57 | # Nested call in boolean operator + | +help: Remove the `if` statement +51 | pass +52 | +53 | # Method call + - if bar.baz(): + - pass +54 + bar.baz() +55 | +56 | # Nested call in boolean operator +57 | if x and foo(): + +RUF050 [*] Empty `if` statement + --> RUF050.py:58:1 + | +57 | # Nested call in boolean operator +58 | / if x and foo(): +59 | | pass + | |________^ +60 | +61 | # Walrus operator with call + | +help: Remove the `if` statement +55 | pass +56 | +57 | # Nested call in boolean operator + - if x and foo(): + - pass +58 + x and foo() +59 | +60 | # Walrus operator with call +61 | if (x := foo()): + +RUF050 [*] Empty `if` statement + --> RUF050.py:62:1 + | +61 | # Walrus operator with call +62 | / if (x := foo()): +63 | | pass + | |________^ +64 | +65 | # Walrus operator without call + | +help: Remove the `if` statement +59 | pass +60 | +61 | # Walrus operator with call + - if (x := foo()): + - pass +62 + (x := foo()) +63 | +64 | # Walrus operator without call +65 | if (x := y): + +RUF050 [*] Empty `if` statement + --> RUF050.py:66:1 + | +65 | # Walrus operator without call +66 | / if (x := y): +67 | | pass + | |________^ +68 | +69 | # Only statement in a suite + | +help: Remove the `if` statement +63 | pass +64 | +65 | # Walrus operator without call + - if (x := y): + - pass +66 + (x := y) +67 | +68 | # Only statement in a suite +69 | class Foo: + +RUF050 [*] Empty `if` statement + --> RUF050.py:71:5 + | +69 | # Only statement in a suite +70 | class Foo: +71 | / if foo(): +72 | | pass + | |____________^ + | +help: Remove the `if` statement +68 | +69 | # Only statement in a suite +70 | class Foo: + - if foo(): + - pass +71 + foo() +72 | +73 | +74 | ### No errors diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_needless_else-2.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_needless_else-2.snap new file mode 100644 index 0000000000000..3b28b22b422c8 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_needless_else-2.snap @@ -0,0 +1,26 @@ +--- +source: crates/ruff_linter/src/rules/ruff/mod.rs +expression: transformed.source_code() +--- +### Errors (both RUF047 and RUF050 converge) + +# RUF047 removes the else, then RUF050 removes the remaining if. + +# Same with elif. +if sys.version_info >= (3, 11): + pass +elif sys.version_info >= (3, 10): + pass + +# Side-effect in condition: RUF047 removes the else, then RUF050 +# replaces the remaining `if` with the condition expression +foo() + + +### No errors + +# Non-empty if body: neither rule fires. +if sys.version_info >= (3, 11): + bar() +else: + baz() diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_needless_else.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_needless_else.snap new file mode 100644 index 0000000000000..74d987c3fe5fc --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_needless_else.snap @@ -0,0 +1,63 @@ +--- +source: crates/ruff_linter/src/rules/ruff/mod.rs +--- +RUF047 [*] Empty `else` clause + --> RUF050_RUF047.py:6:1 + | +4 | if sys.version_info >= (3, 11): +5 | pass +6 | / else: +7 | | pass + | |________^ +8 | +9 | # Same with elif. + | +help: Remove the `else` clause +3 | # RUF047 removes the else, then RUF050 removes the remaining if. +4 | if sys.version_info >= (3, 11): +5 | pass + - else: + - pass +6 | +7 | # Same with elif. +8 | if sys.version_info >= (3, 11): + +RUF047 [*] Empty `else` clause + --> RUF050_RUF047.py:14:1 + | +12 | elif sys.version_info >= (3, 10): +13 | pass +14 | / else: +15 | | pass + | |________^ +16 | +17 | # Side-effect in condition: RUF047 removes the else, then RUF050 + | +help: Remove the `else` clause +11 | pass +12 | elif sys.version_info >= (3, 10): +13 | pass + - else: + - pass +14 | +15 | # Side-effect in condition: RUF047 removes the else, then RUF050 +16 | # replaces the remaining `if` with the condition expression + +RUF047 [*] Empty `else` clause + --> RUF050_RUF047.py:21:1 + | +19 | if foo(): +20 | pass +21 | / else: +22 | | pass + | |________^ + | +help: Remove the `else` clause +18 | # replaces the remaining `if` with the condition expression +19 | if foo(): +20 | pass + - else: + - pass +21 | +22 | +23 | ### No errors diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_unused_import-2.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_unused_import-2.snap new file mode 100644 index 0000000000000..2bc16c2a383fb --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_unused_import-2.snap @@ -0,0 +1,17 @@ +--- +source: crates/ruff_linter/src/rules/ruff/mod.rs +expression: transformed.source_code() +--- +# Reproduces the scenario from issue #9472: +# F401 removes unused imports leaving empty `if` blocks, +# RUF050 removes those blocks, then F401 cleans up the +# now-unused guard imports on subsequent fix iterations. + +import os + +# F401 removes the unused `ExceptionGroup` import, leaving `pass`. +# Then RUF050 removes the empty `if`, and F401 removes `sys`. + +# Already-empty block handled in a single pass by RUF050 + +print(os.getcwd()) diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_unused_import.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_unused_import.snap new file mode 100644 index 0000000000000..dd027c4dce448 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary_if_and_unused_import.snap @@ -0,0 +1,41 @@ +--- +source: crates/ruff_linter/src/rules/ruff/mod.rs +--- +F401 [*] `exceptiongroup.ExceptionGroup` imported but unused + --> RUF050_F401.py:12:32 + | +10 | # Then RUF050 removes the empty `if`, and F401 removes `sys`. +11 | if sys.version_info < (3, 11): +12 | from exceptiongroup import ExceptionGroup + | ^^^^^^^^^^^^^^ +13 | +14 | # Already-empty block handled in a single pass by RUF050 + | +help: Remove unused import: `exceptiongroup.ExceptionGroup` +9 | # F401 removes the unused `ExceptionGroup` import, leaving `pass`. +10 | # Then RUF050 removes the empty `if`, and F401 removes `sys`. +11 | if sys.version_info < (3, 11): + - from exceptiongroup import ExceptionGroup +12 + pass +13 | +14 | # Already-empty block handled in a single pass by RUF050 +15 | if sys.version_info < (3, 11): + +RUF050 [*] Empty `if` statement + --> RUF050_F401.py:15:1 + | +14 | # Already-empty block handled in a single pass by RUF050 +15 | / if sys.version_info < (3, 11): +16 | | pass + | |________^ +17 | +18 | print(os.getcwd()) + | +help: Remove the `if` statement +12 | from exceptiongroup import ExceptionGroup +13 | +14 | # Already-empty block handled in a single pass by RUF050 + - if sys.version_info < (3, 11): + - pass +15 | +16 | print(os.getcwd()) diff --git a/ruff.schema.json b/ruff.schema.json index 9dbae611cc90a..25cf97e691641 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -4196,6 +4196,7 @@ "RUF048", "RUF049", "RUF05", + "RUF050", "RUF051", "RUF052", "RUF053", From b5dcd1ef2c69fcd2cca854a989da339e54cc940d Mon Sep 17 00:00:00 2001 From: seroperson Date: Wed, 25 Mar 2026 19:38:27 +0300 Subject: [PATCH 2/2] Doc entry about __bool__ and __len__ caveats --- crates/ruff_linter/src/rules/ruff/rules/unnecessary_if.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_if.rs b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_if.rs index 6453c4d59270f..5c2c239565c20 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_if.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_if.rs @@ -46,6 +46,12 @@ use crate::{AlwaysFixableViolation, Edit, Fix, fix}; /// replaces the `if` statement with just the condition as an expression /// statement, preserving the side effects. /// +/// Note: conditions consisting solely of a name expression (like +/// `if x: pass`) are treated as side-effect-free, even though `if x` +/// implicitly calls `x.__bool__()` (or `x.__len__()`), which could have +/// side effects if overridden. In practice this is very rare, but if you +/// rely on this behavior, suppress the diagnostic with `# noqa: RUF050`. +/// /// ## Related rules /// - [`needless-else (RUF047)`]: Detects empty `else` clauses. For `if`/`else` /// statements where all branches are empty, `RUF047` first removes the empty