From 112f6e6cc498e802bc4980d520b9f8d26923480e Mon Sep 17 00:00:00 2001 From: Valeriy Savchenko Date: Sat, 26 Aug 2023 13:51:52 +0100 Subject: [PATCH] [refurb] Implement `delete-full-slice` rule (`FURB131`) --- .../resources/test/fixtures/refurb/FURB131.py | 64 ++++++++ .../src/checkers/ast/analyze/statement.rs | 5 +- crates/ruff/src/codes.rs | 1 + crates/ruff/src/rules/refurb/helpers.rs | 134 ++++++++++++++++ crates/ruff/src/rules/refurb/mod.rs | 2 + .../rules/refurb/rules/delete_full_slice.rs | 148 ++++++++++++++++++ crates/ruff/src/rules/refurb/rules/mod.rs | 2 + .../src/rules/refurb/rules/repeated_append.rs | 82 +--------- ...es__refurb__tests__FURB131_FURB131.py.snap | 132 ++++++++++++++++ ruff.schema.json | 2 + 10 files changed, 492 insertions(+), 80 deletions(-) create mode 100644 crates/ruff/resources/test/fixtures/refurb/FURB131.py create mode 100644 crates/ruff/src/rules/refurb/helpers.rs create mode 100644 crates/ruff/src/rules/refurb/rules/delete_full_slice.rs create mode 100644 crates/ruff/src/rules/refurb/snapshots/ruff__rules__refurb__tests__FURB131_FURB131.py.snap diff --git a/crates/ruff/resources/test/fixtures/refurb/FURB131.py b/crates/ruff/resources/test/fixtures/refurb/FURB131.py new file mode 100644 index 00000000000000..ee981b934daae0 --- /dev/null +++ b/crates/ruff/resources/test/fixtures/refurb/FURB131.py @@ -0,0 +1,64 @@ +from typing import Dict, List + +names = {"key": "value"} +nums = [1, 2, 3] +x = 42 +y = "hello" + +# these should match + +# FURB131 +del nums[:] + + +# FURB131 +del names[:] + + +# FURB131 +del x, nums[:] + + +# FURB131 +del y, names[:], x + + +def yes_one(x: list[int]): + # FURB131 + del x[:] + + +def yes_two(x: dict[int, str]): + # FURB131 + del x[:] + + +def yes_three(x: List[int]): + # FURB131 + del x[:] + + +def yes_four(x: Dict[int, str]): + # FURB131 + del x[:] + + +# these should not + +del names["key"] +del nums[0] + +x = 1 +del x + + +del nums[1:2] + + +del nums[:2] +del nums[1:] +del nums[::2] + + +def no_one(param): + del param[:] diff --git a/crates/ruff/src/checkers/ast/analyze/statement.rs b/crates/ruff/src/checkers/ast/analyze/statement.rs index f44b9a8bc65e42..00582b9ad15b54 100644 --- a/crates/ruff/src/checkers/ast/analyze/statement.rs +++ b/crates/ruff/src/checkers/ast/analyze/statement.rs @@ -1460,7 +1460,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) { } } } - Stmt::Delete(ast::StmtDelete { targets, range: _ }) => { + Stmt::Delete(delete @ ast::StmtDelete { targets, range: _ }) => { if checker.enabled(Rule::GlobalStatement) { for target in targets { if let Expr::Name(ast::ExprName { id, .. }) = target { @@ -1468,6 +1468,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) { } } } + if checker.enabled(Rule::DeleteFullSlice) { + refurb::rules::delete_full_slice(checker, delete); + } } Stmt::Expr(ast::StmtExpr { value, range: _ }) => { if checker.enabled(Rule::UselessComparison) { diff --git a/crates/ruff/src/codes.rs b/crates/ruff/src/codes.rs index d7e61976842ce8..6770ad96b5ae81 100644 --- a/crates/ruff/src/codes.rs +++ b/crates/ruff/src/codes.rs @@ -867,6 +867,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { // refurb (Refurb, "113") => (RuleGroup::Unspecified, rules::refurb::rules::RepeatedAppend), + (Refurb, "131") => (RuleGroup::Unspecified, rules::refurb::rules::DeleteFullSlice), _ => return None, }) diff --git a/crates/ruff/src/rules/refurb/helpers.rs b/crates/ruff/src/rules/refurb/helpers.rs new file mode 100644 index 00000000000000..d8e5b913f20660 --- /dev/null +++ b/crates/ruff/src/rules/refurb/helpers.rs @@ -0,0 +1,134 @@ +use ast::{ParameterWithDefault, Parameters}; +use ruff_python_ast::{self as ast, Expr, Stmt}; +use ruff_python_semantic::analyze::type_inference::{PythonType, ResolvedPythonType}; +use ruff_python_semantic::{Binding, BindingKind}; + +use crate::checkers::ast::Checker; + +trait TypeChecker { + const EXPR_TYPE: PythonType; + fn match_annotation(checker: &Checker, annotation: &Expr) -> bool; +} + +fn check_type<'a, T: TypeChecker>(checker: &'a Checker, binding: &'a Binding, name: &str) -> bool { + assert!(binding.source.is_some()); + let stmt = checker.semantic().statement(binding.source.unwrap()); + + match binding.kind { + BindingKind::Assignment => match stmt { + Stmt::Assign(ast::StmtAssign { value, .. }) => { + let value_type: ResolvedPythonType = value.as_ref().into(); + let ResolvedPythonType::Atom(candidate) = value_type + else { + return false; + }; + candidate == T::EXPR_TYPE + } + Stmt::AnnAssign(ast::StmtAnnAssign { annotation, .. }) => { + T::match_annotation(checker, annotation.as_ref()) + } + _ => false, + }, + BindingKind::Argument => match stmt { + Stmt::FunctionDef(ast::StmtFunctionDef { parameters, .. }) => { + let Some(parameter) = find_parameter_by_name(parameters.as_ref(), name) + else { + return false; + }; + let Some(ref annotation) = parameter.parameter.annotation + else { + return false; + }; + T::match_annotation(checker, annotation.as_ref()) + } + _ => false, + }, + BindingKind::Annotation => match stmt { + Stmt::AnnAssign(ast::StmtAnnAssign { annotation, .. }) => { + T::match_annotation(checker, annotation.as_ref()) + } + _ => false, + }, + _ => false, + } +} + +trait AnnotationTypeChecker { + const TYPING_NAME: &'static str; + const BUILTIN_TYPE_NAME: &'static str; + + fn match_annotation(checker: &Checker, annotation: &Expr) -> bool { + let Expr::Subscript(ast::ExprSubscript { value, .. }) = annotation + else { + return false; + }; + Self::match_builtin_type(checker, value) + || checker + .semantic() + .match_typing_expr(value, Self::TYPING_NAME) + } + + fn match_builtin_type(checker: &Checker, type_expr: &Expr) -> bool { + let Expr::Name(ast::ExprName { id, .. }) = type_expr + else { + return false; + }; + id == Self::BUILTIN_TYPE_NAME && checker.semantic().is_builtin(Self::BUILTIN_TYPE_NAME) + } +} + +struct ListChecker; + +impl AnnotationTypeChecker for ListChecker { + const TYPING_NAME: &'static str = "List"; + const BUILTIN_TYPE_NAME: &'static str = "list"; +} + +impl TypeChecker for ListChecker { + const EXPR_TYPE: PythonType = PythonType::List; + fn match_annotation(checker: &Checker, annotation: &Expr) -> bool { + ::match_annotation(checker, annotation) + } +} + +struct DictChecker; + +impl AnnotationTypeChecker for DictChecker { + const TYPING_NAME: &'static str = "Dict"; + const BUILTIN_TYPE_NAME: &'static str = "dict"; +} + +impl TypeChecker for DictChecker { + const EXPR_TYPE: PythonType = PythonType::Dict; + fn match_annotation(checker: &Checker, annotation: &Expr) -> bool { + ::match_annotation(checker, annotation) + } +} + +pub(super) fn is_list<'a>(checker: &'a Checker, binding: &'a Binding, name: &str) -> bool { + check_type::(checker, binding, name) +} + +pub(super) fn is_dict<'a>(checker: &'a Checker, binding: &'a Binding, name: &str) -> bool { + check_type::(checker, binding, name) +} + +#[inline] +fn find_parameter_by_name<'a>( + parameters: &'a Parameters, + name: &'a str, +) -> Option<&'a ParameterWithDefault> { + find_parameter_by_name_impl(¶meters.args, name) + .or_else(|| find_parameter_by_name_impl(¶meters.posonlyargs, name)) + .or_else(|| find_parameter_by_name_impl(¶meters.kwonlyargs, name)) +} + +#[inline] +fn find_parameter_by_name_impl<'a>( + parameters: &'a [ParameterWithDefault], + name: &'a str, +) -> Option<&'a ParameterWithDefault> { + parameters + .iter() + .find(|arg| arg.parameter.name.as_str() == name) +} diff --git a/crates/ruff/src/rules/refurb/mod.rs b/crates/ruff/src/rules/refurb/mod.rs index 0a3584bc5f331c..c40524d1f3f8b5 100644 --- a/crates/ruff/src/rules/refurb/mod.rs +++ b/crates/ruff/src/rules/refurb/mod.rs @@ -1,4 +1,5 @@ //! Rules from [refurb](https://pypi.org/project/refurb/)/ +mod helpers; pub(crate) mod rules; #[cfg(test)] @@ -13,6 +14,7 @@ mod tests { use crate::{assert_messages, settings}; #[test_case(Rule::RepeatedAppend, Path::new("FURB113.py"))] + #[test_case(Rule::DeleteFullSlice, Path::new("FURB131.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); let diagnostics = test_path( diff --git a/crates/ruff/src/rules/refurb/rules/delete_full_slice.rs b/crates/ruff/src/rules/refurb/rules/delete_full_slice.rs new file mode 100644 index 00000000000000..78f66053a1f00b --- /dev/null +++ b/crates/ruff/src/rules/refurb/rules/delete_full_slice.rs @@ -0,0 +1,148 @@ +use ast::Ranged; +use ruff_diagnostics::{AutofixKind, Diagnostic, Edit, Fix, Violation}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::{self as ast, Expr}; +use ruff_python_codegen::Generator; +use ruff_python_semantic::Binding; +use ruff_text_size::TextRange; + +use crate::checkers::ast::Checker; +use crate::registry::AsRule; +use crate::rules::refurb::helpers::{is_dict, is_list}; + +/// ## What it does +/// Checks for delete statements with full slice on lists and dictionaries. +/// +/// ## Why is this bad? +/// It is faster and more succinct to remove all items via the `clear()` method. +/// +/// ## Example +/// ```python +/// names = {"key": "value"} +/// nums = [1, 2, 3] +/// +/// del names[:] +/// del nums[:] +/// ``` +/// +/// Use instead: +/// ```python +/// names = {"key": "value"} +/// nums = [1, 2, 3] +/// +/// names.clear() +/// nums.clear() +/// ``` +#[violation] +pub struct DeleteFullSlice; + +impl Violation for DeleteFullSlice { + const AUTOFIX: AutofixKind = AutofixKind::Sometimes; + + #[derive_message_formats] + fn message(&self) -> String { + format!("Prefer `clear` over deleting the full slice") + } + + fn autofix_title(&self) -> Option { + Some("Replace with `clear()`".to_string()) + } +} + +// FURB131 +pub(crate) fn delete_full_slice(checker: &mut Checker, delete: &ast::StmtDelete) { + // ATM, we can only auto-fix when delete has a single target. + let only_target = delete.targets.len() == 1; + for target in &delete.targets { + let Some(name) = match_full_slice(checker, target) else { + continue; + }; + let mut diagnostic = Diagnostic::new(DeleteFullSlice {}, delete.range); + + if checker.patch(diagnostic.kind.rule()) && only_target { + let replacement = make_suggestion(name, checker.generator()); + diagnostic.set_fix(Fix::suggested(Edit::replacement( + replacement, + delete.start(), + delete.end(), + ))); + } + + checker.diagnostics.push(diagnostic); + } +} + +fn make_suggestion(name: &str, generator: Generator) -> String { + // Here we construct `var.clear()` + // + // And start with construction of `var` + let var = ast::ExprName { + id: name.to_string(), + ctx: ast::ExprContext::Load, + range: TextRange::default(), + }; + // Make `var.clear`. + // NOTE: receiver is the same for all appends and that's why we can take the first. + let attr = ast::ExprAttribute { + value: Box::new(var.into()), + attr: ast::Identifier::new("clear".to_string(), TextRange::default()), + ctx: ast::ExprContext::Load, + range: TextRange::default(), + }; + // Make it into a call `var.clear()` + let call = ast::ExprCall { + func: Box::new(attr.into()), + arguments: ast::Arguments { + args: vec![], + 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()) +} + +fn match_full_slice<'a>(checker: &mut Checker, expr: &'a Expr) -> Option<&'a str> { + // Check that it is del expr[...] + let Expr::Subscript(subscript) = expr else { + return None; + }; + + // Check that it is del expr[:] + let Expr::Slice(ast::ExprSlice{ lower: None, upper: None, step: None, ..}) = subscript.slice.as_ref() else { + return None; + }; + + // Check that it is del var[:] + let Expr::Name(ast::ExprName { id: name, .. }) = subscript.value.as_ref() else { + return None; + }; + + // Let's find definition for var + let scope = checker.semantic().current_scope(); + let bindings: Vec<&Binding> = scope + .get_all(name) + .map(|binding_id| checker.semantic().binding(binding_id)) + .collect(); + + // NOTE: Maybe it is too strict of a limitation, but it seems reasonable. + if bindings.len() != 1 { + return None; + } + + let binding = bindings[0]; + // It should only apply to variables that are known to be lists or dicts. + if binding.source.is_none() + || !(is_dict(checker, binding, name) || is_list(checker, binding, name)) + { + return None; + } + + // Name is needed for the fix suggestion. + Some(name) +} diff --git a/crates/ruff/src/rules/refurb/rules/mod.rs b/crates/ruff/src/rules/refurb/rules/mod.rs index b4b2317a3c8383..c897f03072c48b 100644 --- a/crates/ruff/src/rules/refurb/rules/mod.rs +++ b/crates/ruff/src/rules/refurb/rules/mod.rs @@ -1,3 +1,5 @@ +pub(crate) use delete_full_slice::*; pub(crate) use repeated_append::*; +mod delete_full_slice; mod repeated_append; diff --git a/crates/ruff/src/rules/refurb/rules/repeated_append.rs b/crates/ruff/src/rules/refurb/rules/repeated_append.rs index 22ce1e49c5d17e..128e05b414f107 100644 --- a/crates/ruff/src/rules/refurb/rules/repeated_append.rs +++ b/crates/ruff/src/rules/refurb/rules/repeated_append.rs @@ -1,16 +1,16 @@ use std::collections::HashMap; -use ast::{traversal, ParameterWithDefault, Parameters, Ranged}; +use ast::{traversal, Ranged}; use ruff_diagnostics::{AutofixKind, Diagnostic, Edit, Fix, Violation}; use ruff_macros::{derive_message_formats, violation}; use ruff_python_ast::{self as ast, Expr, ExprName, Stmt}; use ruff_python_codegen::Generator; -use ruff_python_semantic::analyze::type_inference::{PythonType, ResolvedPythonType}; -use ruff_python_semantic::{Binding, BindingKind, Definition, DefinitionId, NodeId}; +use ruff_python_semantic::{Binding, Definition, DefinitionId, NodeId}; use ruff_text_size::TextRange; use crate::checkers::ast::Checker; use crate::registry::AsRule; +use crate::rules::refurb::helpers::is_list; /// ## What it does /// Checks for consecutive calls to `append`. @@ -299,79 +299,3 @@ fn match_append<'a>(checker: &'a Checker, stmt: &'a Stmt) -> Option> argument: arguments.args.first().unwrap(), }) } - -fn is_list<'a>(checker: &'a Checker, binding: &'a Binding, name: &str) -> bool { - assert!(binding.source.is_some()); - let stmt = checker.semantic().statement(binding.source.unwrap()); - - match binding.kind { - BindingKind::Assignment => match stmt { - Stmt::Assign(ast::StmtAssign { value, .. }) => { - let value_type: ResolvedPythonType = value.as_ref().into(); - let ResolvedPythonType::Atom(candidate) = value_type else { - return false; - }; - matches!(candidate, PythonType::List) - } - Stmt::AnnAssign(ast::StmtAnnAssign { annotation, .. }) => { - is_list_annotation(checker, annotation.as_ref()) - } - _ => false, - }, - BindingKind::Argument => match stmt { - Stmt::FunctionDef(ast::StmtFunctionDef { parameters, .. }) => { - let Some(parameter) = find_parameter_by_name(parameters.as_ref(), name) else { - return false; - }; - let Some(ref annotation) = parameter.parameter.annotation else { - return false; - }; - is_list_annotation(checker, annotation.as_ref()) - } - _ => false, - }, - BindingKind::Annotation => match stmt { - Stmt::AnnAssign(ast::StmtAnnAssign { annotation, .. }) => { - is_list_annotation(checker, annotation.as_ref()) - } - _ => false, - }, - _ => false, - } -} - -#[inline] -fn is_list_annotation(checker: &Checker, annotation: &Expr) -> bool { - let Expr::Subscript(ast::ExprSubscript { value, .. }) = annotation else { - return false; - }; - match_builtin_list_type(checker, value) || checker.semantic().match_typing_expr(value, "List") -} - -#[inline] -fn match_builtin_list_type(checker: &Checker, type_expr: &Expr) -> bool { - let Expr::Name(ast::ExprName { id, .. }) = type_expr else { - return false; - }; - id == "list" && checker.semantic().is_builtin("list") -} - -#[inline] -fn find_parameter_by_name<'a>( - parameters: &'a Parameters, - name: &'a str, -) -> Option<&'a ParameterWithDefault> { - find_parameter_by_name_impl(¶meters.args, name) - .or_else(|| find_parameter_by_name_impl(¶meters.posonlyargs, name)) - .or_else(|| find_parameter_by_name_impl(¶meters.kwonlyargs, name)) -} - -#[inline] -fn find_parameter_by_name_impl<'a>( - parameters: &'a [ParameterWithDefault], - name: &'a str, -) -> Option<&'a ParameterWithDefault> { - parameters - .iter() - .find(|arg| arg.parameter.name.as_str() == name) -} diff --git a/crates/ruff/src/rules/refurb/snapshots/ruff__rules__refurb__tests__FURB131_FURB131.py.snap b/crates/ruff/src/rules/refurb/snapshots/ruff__rules__refurb__tests__FURB131_FURB131.py.snap new file mode 100644 index 00000000000000..3c173bebba0d7b --- /dev/null +++ b/crates/ruff/src/rules/refurb/snapshots/ruff__rules__refurb__tests__FURB131_FURB131.py.snap @@ -0,0 +1,132 @@ +--- +source: crates/ruff/src/rules/refurb/mod.rs +--- +FURB131.py:11:1: FURB131 [*] Prefer `clear` over deleting the full slice + | +10 | # FURB131 +11 | del nums[:] + | ^^^^^^^^^^^ FURB131 + | + = help: Replace with `clear()` + +ℹ Suggested fix +8 8 | # these should match +9 9 | +10 10 | # FURB131 +11 |-del nums[:] + 11 |+nums.clear() +12 12 | +13 13 | +14 14 | # FURB131 + +FURB131.py:15:1: FURB131 [*] Prefer `clear` over deleting the full slice + | +14 | # FURB131 +15 | del names[:] + | ^^^^^^^^^^^^ FURB131 + | + = help: Replace with `clear()` + +ℹ Suggested fix +12 12 | +13 13 | +14 14 | # FURB131 +15 |-del names[:] + 15 |+names.clear() +16 16 | +17 17 | +18 18 | # FURB131 + +FURB131.py:19:1: FURB131 Prefer `clear` over deleting the full slice + | +18 | # FURB131 +19 | del x, nums[:] + | ^^^^^^^^^^^^^^ FURB131 + | + = help: Replace with `clear()` + +FURB131.py:23:1: FURB131 Prefer `clear` over deleting the full slice + | +22 | # FURB131 +23 | del y, names[:], x + | ^^^^^^^^^^^^^^^^^^ FURB131 + | + = help: Replace with `clear()` + +FURB131.py:28:5: FURB131 [*] Prefer `clear` over deleting the full slice + | +26 | def yes_one(x: list[int]): +27 | # FURB131 +28 | del x[:] + | ^^^^^^^^ FURB131 + | + = help: Replace with `clear()` + +ℹ Suggested fix +25 25 | +26 26 | def yes_one(x: list[int]): +27 27 | # FURB131 +28 |- del x[:] + 28 |+ x.clear() +29 29 | +30 30 | +31 31 | def yes_two(x: dict[int, str]): + +FURB131.py:33:5: FURB131 [*] Prefer `clear` over deleting the full slice + | +31 | def yes_two(x: dict[int, str]): +32 | # FURB131 +33 | del x[:] + | ^^^^^^^^ FURB131 + | + = help: Replace with `clear()` + +ℹ Suggested fix +30 30 | +31 31 | def yes_two(x: dict[int, str]): +32 32 | # FURB131 +33 |- del x[:] + 33 |+ x.clear() +34 34 | +35 35 | +36 36 | def yes_three(x: List[int]): + +FURB131.py:38:5: FURB131 [*] Prefer `clear` over deleting the full slice + | +36 | def yes_three(x: List[int]): +37 | # FURB131 +38 | del x[:] + | ^^^^^^^^ FURB131 + | + = help: Replace with `clear()` + +ℹ Suggested fix +35 35 | +36 36 | def yes_three(x: List[int]): +37 37 | # FURB131 +38 |- del x[:] + 38 |+ x.clear() +39 39 | +40 40 | +41 41 | def yes_four(x: Dict[int, str]): + +FURB131.py:43:5: FURB131 [*] Prefer `clear` over deleting the full slice + | +41 | def yes_four(x: Dict[int, str]): +42 | # FURB131 +43 | del x[:] + | ^^^^^^^^ FURB131 + | + = help: Replace with `clear()` + +ℹ Suggested fix +40 40 | +41 41 | def yes_four(x: Dict[int, str]): +42 42 | # FURB131 +43 |- del x[:] + 43 |+ x.clear() +44 44 | +45 45 | +46 46 | # these should not + + diff --git a/ruff.schema.json b/ruff.schema.json index 53f8fdb187f62c..9095e32319d80d 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -2047,6 +2047,8 @@ "FURB1", "FURB11", "FURB113", + "FURB13", + "FURB131", "G", "G0", "G00",