mirror of https://github.com/astral-sh/ruff
[refurb] Implement `check-and-remove-from-set` rule (`FURB132`) (#6904)
## Summary This PR is a continuation of #6897 and #6702 and me replicating `refurb` rules (#1348). It adds support for [FURB132](https://github.com/dosisod/refurb/blob/master/refurb/checks/builtin/set_discard.py) ## Test Plan I included a new test + checked that all other tests pass.
This commit is contained in:
parent
c448b4086a
commit
7e36284684
|
|
@ -0,0 +1,80 @@
|
||||||
|
from typing import Set
|
||||||
|
from some.package.name import foo, bar
|
||||||
|
|
||||||
|
|
||||||
|
s = set()
|
||||||
|
s2 = {}
|
||||||
|
s3: set[int] = foo()
|
||||||
|
|
||||||
|
# these should match
|
||||||
|
|
||||||
|
# FURB132
|
||||||
|
if "x" in s:
|
||||||
|
s.remove("x")
|
||||||
|
|
||||||
|
|
||||||
|
# FURB132
|
||||||
|
if "x" in s2:
|
||||||
|
s2.remove("x")
|
||||||
|
|
||||||
|
|
||||||
|
# FURB132
|
||||||
|
if "x" in s3:
|
||||||
|
s3.remove("x")
|
||||||
|
|
||||||
|
|
||||||
|
var = "y"
|
||||||
|
# FURB132
|
||||||
|
if var in s:
|
||||||
|
s.remove(var)
|
||||||
|
|
||||||
|
|
||||||
|
if f"{var}:{var}" in s:
|
||||||
|
s.remove(f"{var}:{var}")
|
||||||
|
|
||||||
|
|
||||||
|
def identity(x):
|
||||||
|
return x
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: FURB132
|
||||||
|
if identity("x") in s2:
|
||||||
|
s2.remove(identity("x"))
|
||||||
|
|
||||||
|
|
||||||
|
# these should not
|
||||||
|
|
||||||
|
if "x" in s:
|
||||||
|
s.remove("y")
|
||||||
|
|
||||||
|
s.discard("x")
|
||||||
|
|
||||||
|
s2 = set()
|
||||||
|
|
||||||
|
if "x" in s:
|
||||||
|
s2.remove("x")
|
||||||
|
|
||||||
|
if "x" in s:
|
||||||
|
s.remove("x")
|
||||||
|
print("removed item")
|
||||||
|
|
||||||
|
if bar() in s:
|
||||||
|
# bar() might have a side effect
|
||||||
|
s.remove(bar())
|
||||||
|
|
||||||
|
if "x" in s:
|
||||||
|
s.remove("x")
|
||||||
|
else:
|
||||||
|
print("not found")
|
||||||
|
|
||||||
|
class Container:
|
||||||
|
def remove(self, item) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def __contains__(self, other) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
c = Container()
|
||||||
|
|
||||||
|
if "x" in c:
|
||||||
|
c.remove("x")
|
||||||
|
|
@ -1056,6 +1056,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
|
||||||
checker.diagnostics.push(diagnostic);
|
checker.diagnostics.push(diagnostic);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if checker.enabled(Rule::CheckAndRemoveFromSet) {
|
||||||
|
refurb::rules::check_and_remove_from_set(checker, if_);
|
||||||
|
}
|
||||||
if checker.source_type.is_stub() {
|
if checker.source_type.is_stub() {
|
||||||
if checker.any_enabled(&[
|
if checker.any_enabled(&[
|
||||||
Rule::UnrecognizedVersionInfoCheck,
|
Rule::UnrecognizedVersionInfoCheck,
|
||||||
|
|
|
||||||
|
|
@ -868,5 +868,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
|
||||||
// refurb
|
// refurb
|
||||||
(Refurb, "113") => (RuleGroup::Nursery, rules::refurb::rules::RepeatedAppend),
|
(Refurb, "113") => (RuleGroup::Nursery, rules::refurb::rules::RepeatedAppend),
|
||||||
(Refurb, "131") => (RuleGroup::Nursery, rules::refurb::rules::DeleteFullSlice),
|
(Refurb, "131") => (RuleGroup::Nursery, rules::refurb::rules::DeleteFullSlice),
|
||||||
|
(Refurb, "132") => (RuleGroup::Nursery, rules::refurb::rules::CheckAndRemoveFromSet),
|
||||||
|
|
||||||
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,14 @@ impl BuiltinTypeChecker for DictChecker {
|
||||||
const EXPR_TYPE: PythonType = PythonType::Dict;
|
const EXPR_TYPE: PythonType = PythonType::Dict;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SetChecker;
|
||||||
|
|
||||||
|
impl BuiltinTypeChecker for SetChecker {
|
||||||
|
const BUILTIN_TYPE_NAME: &'static str = "set";
|
||||||
|
const TYPING_NAME: &'static str = "Set";
|
||||||
|
const EXPR_TYPE: PythonType = PythonType::Set;
|
||||||
|
}
|
||||||
|
|
||||||
/// Test whether the given binding (and the given name) can be considered a list.
|
/// Test whether the given binding (and the given name) can be considered a list.
|
||||||
/// For this, we check what value might be associated with it through it's initialization and
|
/// For this, we check what value might be associated with it through it's initialization and
|
||||||
/// what annotation it has (we consider `list` and `typing.List`).
|
/// what annotation it has (we consider `list` and `typing.List`).
|
||||||
|
|
@ -163,6 +171,13 @@ pub(super) fn is_dict<'a>(binding: &'a Binding, name: &str, semantic: &'a Semant
|
||||||
check_type::<DictChecker>(binding, name, semantic)
|
check_type::<DictChecker>(binding, name, semantic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test whether the given binding (and the given name) can be considered a set.
|
||||||
|
/// For this, we check what value might be associated with it through it's initialization and
|
||||||
|
/// what annotation it has (we consider `set` and `typing.Set`).
|
||||||
|
pub(super) fn is_set<'a>(binding: &'a Binding, name: &str, semantic: &'a SemanticModel) -> bool {
|
||||||
|
check_type::<SetChecker>(binding, name, semantic)
|
||||||
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn find_parameter_by_name<'a>(
|
fn find_parameter_by_name<'a>(
|
||||||
parameters: &'a Parameters,
|
parameters: &'a Parameters,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ mod tests {
|
||||||
|
|
||||||
#[test_case(Rule::RepeatedAppend, Path::new("FURB113.py"))]
|
#[test_case(Rule::RepeatedAppend, Path::new("FURB113.py"))]
|
||||||
#[test_case(Rule::DeleteFullSlice, Path::new("FURB131.py"))]
|
#[test_case(Rule::DeleteFullSlice, Path::new("FURB131.py"))]
|
||||||
|
#[test_case(Rule::CheckAndRemoveFromSet, Path::new("FURB132.py"))]
|
||||||
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
|
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
|
||||||
let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy());
|
let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy());
|
||||||
let diagnostics = test_path(
|
let diagnostics = test_path(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
use ruff_diagnostics::{AlwaysAutofixableViolation, Diagnostic, Edit, Fix};
|
||||||
|
use ruff_macros::{derive_message_formats, violation};
|
||||||
|
use ruff_python_ast::comparable::ComparableExpr;
|
||||||
|
use ruff_python_ast::helpers::contains_effect;
|
||||||
|
use ruff_python_ast::{self as ast, CmpOp, Expr, Stmt};
|
||||||
|
use ruff_python_codegen::Generator;
|
||||||
|
use ruff_text_size::{Ranged, TextRange};
|
||||||
|
|
||||||
|
use crate::autofix::snippet::SourceCodeSnippet;
|
||||||
|
use crate::checkers::ast::Checker;
|
||||||
|
use crate::registry::AsRule;
|
||||||
|
use crate::rules::refurb::helpers::is_set;
|
||||||
|
|
||||||
|
/// ## What it does
|
||||||
|
/// Checks for uses of `set#remove` that can be replaced with `set#discard`.
|
||||||
|
///
|
||||||
|
/// ## Why is this bad?
|
||||||
|
/// If an element should be removed from a set if it is present, it is more
|
||||||
|
/// succinct and idiomatic to use `discard`.
|
||||||
|
///
|
||||||
|
/// ## Example
|
||||||
|
/// ```python
|
||||||
|
/// nums = {123, 456}
|
||||||
|
///
|
||||||
|
/// if 123 in nums:
|
||||||
|
/// nums.remove(123)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Use instead:
|
||||||
|
/// ```python
|
||||||
|
/// nums = {123, 456}
|
||||||
|
///
|
||||||
|
/// nums.discard(123)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ## References
|
||||||
|
/// - [Python documentation: `set.discard()`](https://docs.python.org/3/library/stdtypes.html?highlight=list#frozenset.discard)
|
||||||
|
#[violation]
|
||||||
|
pub struct CheckAndRemoveFromSet {
|
||||||
|
element: SourceCodeSnippet,
|
||||||
|
set: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CheckAndRemoveFromSet {
|
||||||
|
fn suggestion(&self) -> String {
|
||||||
|
let set = &self.set;
|
||||||
|
let element = self.element.truncated_display();
|
||||||
|
format!("{set}.discard({element})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlwaysAutofixableViolation for CheckAndRemoveFromSet {
|
||||||
|
#[derive_message_formats]
|
||||||
|
fn message(&self) -> String {
|
||||||
|
let suggestion = self.suggestion();
|
||||||
|
format!("Use `{suggestion}` instead of check and `remove`")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn autofix_title(&self) -> String {
|
||||||
|
let suggestion = self.suggestion();
|
||||||
|
format!("Replace with `{suggestion}`")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FURB132
|
||||||
|
pub(crate) fn check_and_remove_from_set(checker: &mut Checker, if_stmt: &ast::StmtIf) {
|
||||||
|
// In order to fit the profile, we need if without else clauses and with only one statement in its body.
|
||||||
|
if if_stmt.body.len() != 1 || !if_stmt.elif_else_clauses.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The `if` test should be `element in set`.
|
||||||
|
let Some((check_element, check_set)) = match_check(if_stmt) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The `if` body should be `set.remove(element)`.
|
||||||
|
let Some((remove_element, remove_set)) = match_remove(if_stmt) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// `
|
||||||
|
// `set` in the check should be the same as `set` in the body
|
||||||
|
if check_set.id != remove_set.id
|
||||||
|
// `element` in the check should be the same as `element` in the body
|
||||||
|
|| !compare(&check_element.into(), &remove_element.into())
|
||||||
|
// `element` shouldn't have a side effect, otherwise we might change the semantics of the program.
|
||||||
|
|| contains_effect(check_element, |id| checker.semantic().is_builtin(id))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if what we assume is set is indeed a set.
|
||||||
|
if !checker
|
||||||
|
.semantic()
|
||||||
|
.resolve_name(check_set)
|
||||||
|
.map(|binding_id| checker.semantic().binding(binding_id))
|
||||||
|
.map_or(false, |binding| {
|
||||||
|
is_set(binding, &check_set.id, checker.semantic())
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut diagnostic = Diagnostic::new(
|
||||||
|
CheckAndRemoveFromSet {
|
||||||
|
element: SourceCodeSnippet::from_str(checker.locator().slice(check_element)),
|
||||||
|
set: check_set.id.to_string(),
|
||||||
|
},
|
||||||
|
if_stmt.range(),
|
||||||
|
);
|
||||||
|
if checker.patch(diagnostic.kind.rule()) {
|
||||||
|
diagnostic.set_fix(Fix::suggested(Edit::replacement(
|
||||||
|
make_suggestion(check_set, check_element, checker.generator()),
|
||||||
|
if_stmt.start(),
|
||||||
|
if_stmt.end(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
checker.diagnostics.push(diagnostic);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare(lhs: &ComparableExpr, rhs: &ComparableExpr) -> bool {
|
||||||
|
lhs == rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match `if` condition to be `expr in name`, returns a tuple of (`expr`, `name`) on success.
|
||||||
|
fn match_check(if_stmt: &ast::StmtIf) -> Option<(&Expr, &ast::ExprName)> {
|
||||||
|
let ast::ExprCompare {
|
||||||
|
ops,
|
||||||
|
left,
|
||||||
|
comparators,
|
||||||
|
..
|
||||||
|
} = if_stmt.test.as_compare_expr()?;
|
||||||
|
|
||||||
|
if ops.as_slice() != [CmpOp::In] {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let [Expr::Name(right @ ast::ExprName { .. })] = comparators.as_slice() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
Some((left.as_ref(), right))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match `if` body to be `name.remove(expr)`, returns a tuple of (`expr`, `name`) on success.
|
||||||
|
fn match_remove(if_stmt: &ast::StmtIf) -> Option<(&Expr, &ast::ExprName)> {
|
||||||
|
let [Stmt::Expr(ast::StmtExpr { value: expr, .. })] = if_stmt.body.as_slice() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let ast::ExprCall {
|
||||||
|
func: attr,
|
||||||
|
arguments: ast::Arguments { args, keywords, .. },
|
||||||
|
..
|
||||||
|
} = expr.as_call_expr()?;
|
||||||
|
|
||||||
|
let ast::ExprAttribute {
|
||||||
|
value: receiver,
|
||||||
|
attr: func_name,
|
||||||
|
..
|
||||||
|
} = attr.as_attribute_expr()?;
|
||||||
|
|
||||||
|
let Expr::Name(ref set @ ast::ExprName { .. }) = receiver.as_ref() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let [arg] = args.as_slice() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
if func_name != "remove" || !keywords.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((arg, set))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct the fix suggestion, ie `set.discard(element)`.
|
||||||
|
fn make_suggestion(set: &ast::ExprName, element: &Expr, generator: Generator) -> String {
|
||||||
|
// Here we construct `set.discard(element)`
|
||||||
|
//
|
||||||
|
// Let's make `set.discard`.
|
||||||
|
let attr = ast::ExprAttribute {
|
||||||
|
value: Box::new(set.clone().into()),
|
||||||
|
attr: ast::Identifier::new("discard".to_string(), TextRange::default()),
|
||||||
|
ctx: ast::ExprContext::Load,
|
||||||
|
range: TextRange::default(),
|
||||||
|
};
|
||||||
|
// Make the actual call `set.discard(element)`
|
||||||
|
let call = ast::ExprCall {
|
||||||
|
func: Box::new(attr.into()),
|
||||||
|
arguments: ast::Arguments {
|
||||||
|
args: vec![element.clone()],
|
||||||
|
keywords: vec![],
|
||||||
|
range: TextRange::default(),
|
||||||
|
},
|
||||||
|
range: TextRange::default(),
|
||||||
|
};
|
||||||
|
// And finally, turn it into a statement.
|
||||||
|
let stmt = ast::StmtExpr {
|
||||||
|
value: Box::new(call.into()),
|
||||||
|
range: TextRange::default(),
|
||||||
|
};
|
||||||
|
generator.stmt(&stmt.into())
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
pub(crate) use check_and_remove_from_set::*;
|
||||||
pub(crate) use delete_full_slice::*;
|
pub(crate) use delete_full_slice::*;
|
||||||
pub(crate) use repeated_append::*;
|
pub(crate) use repeated_append::*;
|
||||||
|
|
||||||
|
mod check_and_remove_from_set;
|
||||||
mod delete_full_slice;
|
mod delete_full_slice;
|
||||||
|
|
||||||
mod repeated_append;
|
mod repeated_append;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
---
|
||||||
|
source: crates/ruff/src/rules/refurb/mod.rs
|
||||||
|
---
|
||||||
|
FURB132.py:12:1: FURB132 [*] Use `s.discard("x")` instead of check and `remove`
|
||||||
|
|
|
||||||
|
11 | # FURB132
|
||||||
|
12 | / if "x" in s:
|
||||||
|
13 | | s.remove("x")
|
||||||
|
| |_________________^ FURB132
|
||||||
|
|
|
||||||
|
= help: Replace with `s.discard("x")`
|
||||||
|
|
||||||
|
ℹ Suggested fix
|
||||||
|
9 9 | # these should match
|
||||||
|
10 10 |
|
||||||
|
11 11 | # FURB132
|
||||||
|
12 |-if "x" in s:
|
||||||
|
13 |- s.remove("x")
|
||||||
|
12 |+s.discard("x")
|
||||||
|
14 13 |
|
||||||
|
15 14 |
|
||||||
|
16 15 | # FURB132
|
||||||
|
|
||||||
|
FURB132.py:22:1: FURB132 [*] Use `s3.discard("x")` instead of check and `remove`
|
||||||
|
|
|
||||||
|
21 | # FURB132
|
||||||
|
22 | / if "x" in s3:
|
||||||
|
23 | | s3.remove("x")
|
||||||
|
| |__________________^ FURB132
|
||||||
|
|
|
||||||
|
= help: Replace with `s3.discard("x")`
|
||||||
|
|
||||||
|
ℹ Suggested fix
|
||||||
|
19 19 |
|
||||||
|
20 20 |
|
||||||
|
21 21 | # FURB132
|
||||||
|
22 |-if "x" in s3:
|
||||||
|
23 |- s3.remove("x")
|
||||||
|
22 |+s3.discard("x")
|
||||||
|
24 23 |
|
||||||
|
25 24 |
|
||||||
|
26 25 | var = "y"
|
||||||
|
|
||||||
|
FURB132.py:28:1: FURB132 [*] Use `s.discard(var)` instead of check and `remove`
|
||||||
|
|
|
||||||
|
26 | var = "y"
|
||||||
|
27 | # FURB132
|
||||||
|
28 | / if var in s:
|
||||||
|
29 | | s.remove(var)
|
||||||
|
| |_________________^ FURB132
|
||||||
|
|
|
||||||
|
= help: Replace with `s.discard(var)`
|
||||||
|
|
||||||
|
ℹ Suggested fix
|
||||||
|
25 25 |
|
||||||
|
26 26 | var = "y"
|
||||||
|
27 27 | # FURB132
|
||||||
|
28 |-if var in s:
|
||||||
|
29 |- s.remove(var)
|
||||||
|
28 |+s.discard(var)
|
||||||
|
30 29 |
|
||||||
|
31 30 |
|
||||||
|
32 31 | if f"{var}:{var}" in s:
|
||||||
|
|
||||||
|
FURB132.py:32:1: FURB132 [*] Use `s.discard(f"{var}:{var}")` instead of check and `remove`
|
||||||
|
|
|
||||||
|
32 | / if f"{var}:{var}" in s:
|
||||||
|
33 | | s.remove(f"{var}:{var}")
|
||||||
|
| |____________________________^ FURB132
|
||||||
|
|
|
||||||
|
= help: Replace with `s.discard(f"{var}:{var}")`
|
||||||
|
|
||||||
|
ℹ Suggested fix
|
||||||
|
29 29 | s.remove(var)
|
||||||
|
30 30 |
|
||||||
|
31 31 |
|
||||||
|
32 |-if f"{var}:{var}" in s:
|
||||||
|
33 |- s.remove(f"{var}:{var}")
|
||||||
|
32 |+s.discard(f"{var}:{var}")
|
||||||
|
34 33 |
|
||||||
|
35 34 |
|
||||||
|
36 35 | def identity(x):
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2046,6 +2046,7 @@
|
||||||
"FURB",
|
"FURB",
|
||||||
"FURB113",
|
"FURB113",
|
||||||
"FURB131",
|
"FURB131",
|
||||||
|
"FURB132",
|
||||||
"G",
|
"G",
|
||||||
"G0",
|
"G0",
|
||||||
"G00",
|
"G00",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue