[ruff] Implement a recursive check for RUF060 (#17976)

<!--
Thank you for contributing to Ruff! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->

## Summary

<!-- What's the purpose of the change? What does it do, and why? -->
The existing implementation of RUF060 (InEmptyCollection) is not
recursive, meaning that although set([]) results in an empty collection,
the existing code fails it because set is taking an argument.

The updated implementation allows set and frozenset to take empty
collection as positional argument (which results in empty
set/frozenset).

## Test Plan

Added test cases for recursive cases + updated snapshot (see RUF060.py).

---------

Co-authored-by: Marcus Näslund <marcus.naslund@kognity.com>
This commit is contained in:
Marcus Näslund 2025-05-12 22:17:13 +02:00 committed by GitHub
parent d7ef01401c
commit b2d9f59937
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 59 additions and 9 deletions

View file

@ -19,6 +19,9 @@ b'c' in b""
b"a" in bytearray()
b"a" in bytes()
1 in frozenset()
1 in set(set())
2 in frozenset([])
'' in set("")
# OK
1 in [2]
@ -35,3 +38,7 @@ b'c' in b"x"
b"a" in bytearray([2])
b"a" in bytes("a", "utf-8")
1 in frozenset("c")
1 in set(set((1,2)))
1 in set(set([1]))
'' in {""}
frozenset() in {frozenset()}

View file

@ -1,6 +1,7 @@
use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, ViolationMetadata};
use ruff_python_ast::{self as ast, CmpOp, Expr};
use ruff_python_semantic::SemanticModel;
use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
@ -48,6 +49,14 @@ pub(crate) fn in_empty_collection(checker: &Checker, compare: &ast::ExprCompare)
};
let semantic = checker.semantic();
if is_empty(right, semantic) {
checker.report_diagnostic(Diagnostic::new(InEmptyCollection, compare.range()));
}
}
fn is_empty(expr: &Expr, semantic: &SemanticModel) -> bool {
let set_methods = ["set", "frozenset"];
let collection_methods = [
"list",
"tuple",
@ -59,7 +68,7 @@ pub(crate) fn in_empty_collection(checker: &Checker, compare: &ast::ExprCompare)
"str",
];
let is_empty_collection = match right {
match expr {
Expr::List(ast::ExprList { elts, .. }) => elts.is_empty(),
Expr::Tuple(ast::ExprTuple { elts, .. }) => elts.is_empty(),
Expr::Set(ast::ExprSet { elts, .. }) => elts.is_empty(),
@ -75,15 +84,19 @@ pub(crate) fn in_empty_collection(checker: &Checker, compare: &ast::ExprCompare)
arguments,
range: _,
}) => {
arguments.is_empty()
&& collection_methods
if arguments.is_empty() {
collection_methods
.iter()
.any(|s| semantic.match_builtin_expr(func, s))
} else if let Some(arg) = arguments.find_positional(0) {
set_methods
.iter()
.any(|s| semantic.match_builtin_expr(func, s))
&& is_empty(arg, semantic)
} else {
false
}
}
_ => false,
};
if is_empty_collection {
checker.report_diagnostic(Diagnostic::new(InEmptyCollection, compare.range()));
}
}

View file

@ -187,6 +187,7 @@ RUF060.py:20:1: RUF060 Unnecessary membership test on empty collection
20 | b"a" in bytes()
| ^^^^^^^^^^^^^^^ RUF060
21 | 1 in frozenset()
22 | 1 in set(set())
|
RUF060.py:21:1: RUF060 Unnecessary membership test on empty collection
@ -195,6 +196,35 @@ RUF060.py:21:1: RUF060 Unnecessary membership test on empty collection
20 | b"a" in bytes()
21 | 1 in frozenset()
| ^^^^^^^^^^^^^^^^ RUF060
22 |
23 | # OK
22 | 1 in set(set())
23 | 2 in frozenset([])
|
RUF060.py:22:1: RUF060 Unnecessary membership test on empty collection
|
20 | b"a" in bytes()
21 | 1 in frozenset()
22 | 1 in set(set())
| ^^^^^^^^^^^^^^^ RUF060
23 | 2 in frozenset([])
24 | '' in set("")
|
RUF060.py:23:1: RUF060 Unnecessary membership test on empty collection
|
21 | 1 in frozenset()
22 | 1 in set(set())
23 | 2 in frozenset([])
| ^^^^^^^^^^^^^^^^^^ RUF060
24 | '' in set("")
|
RUF060.py:24:1: RUF060 Unnecessary membership test on empty collection
|
22 | 1 in set(set())
23 | 2 in frozenset([])
24 | '' in set("")
| ^^^^^^^^^^^^^ RUF060
25 |
26 | # OK
|