-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
## Summary Implement [`no-single-item-in`](https://github.com/dosisod/refurb/blob/master/refurb/checks/iterable/no_single_item_in.py) as `single-item-membership-test` (`FURB171`). Uses the helper function `generate_comparison` from the `pycodestyle` implementations; this function should probably be moved, but I am not sure where at the moment. Update: moved it to `ruff_python_ast::helpers`. Related to #1348. ## Test Plan `cargo test`
- Loading branch information
Showing
12 changed files
with
368 additions
and
63 deletions.
There are no files selected for viewing
45 changes: 45 additions & 0 deletions
45
crates/ruff_linter/resources/test/fixtures/refurb/FURB171.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
# Errors. | ||
|
||
if 1 in (1,): | ||
print("Single-element tuple") | ||
|
||
if 1 in [1]: | ||
print("Single-element list") | ||
|
||
if 1 in {1}: | ||
print("Single-element set") | ||
|
||
if "a" in "a": | ||
print("Single-element string") | ||
|
||
if 1 not in (1,): | ||
print("Check `not in` membership test") | ||
|
||
if not 1 in (1,): | ||
print("Check the negated membership test") | ||
|
||
# Non-errors. | ||
|
||
if 1 in (1, 2): | ||
pass | ||
|
||
if 1 in [1, 2]: | ||
pass | ||
|
||
if 1 in {1, 2}: | ||
pass | ||
|
||
if "a" in "ab": | ||
pass | ||
|
||
if 1 == (1,): | ||
pass | ||
|
||
if 1 > [1]: | ||
pass | ||
|
||
if 1 is {1}: | ||
pass | ||
|
||
if "a" == "a": | ||
pass |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,62 +1,3 @@ | ||
use ruff_python_ast::node::AnyNodeRef; | ||
use ruff_python_ast::parenthesize::parenthesized_range; | ||
use ruff_python_ast::{CmpOp, Expr}; | ||
use ruff_python_trivia::CommentRanges; | ||
use ruff_source_file::Locator; | ||
use ruff_text_size::Ranged; | ||
|
||
pub(super) fn is_ambiguous_name(name: &str) -> bool { | ||
name == "l" || name == "I" || name == "O" | ||
} | ||
|
||
pub(super) fn generate_comparison( | ||
left: &Expr, | ||
ops: &[CmpOp], | ||
comparators: &[Expr], | ||
parent: AnyNodeRef, | ||
comment_ranges: &CommentRanges, | ||
locator: &Locator, | ||
) -> String { | ||
let start = left.start(); | ||
let end = comparators.last().map_or_else(|| left.end(), Ranged::end); | ||
let mut contents = String::with_capacity(usize::from(end - start)); | ||
|
||
// Add the left side of the comparison. | ||
contents.push_str( | ||
locator.slice( | ||
parenthesized_range(left.into(), parent, comment_ranges, locator.contents()) | ||
.unwrap_or(left.range()), | ||
), | ||
); | ||
|
||
for (op, comparator) in ops.iter().zip(comparators) { | ||
// Add the operator. | ||
contents.push_str(match op { | ||
CmpOp::Eq => " == ", | ||
CmpOp::NotEq => " != ", | ||
CmpOp::Lt => " < ", | ||
CmpOp::LtE => " <= ", | ||
CmpOp::Gt => " > ", | ||
CmpOp::GtE => " >= ", | ||
CmpOp::In => " in ", | ||
CmpOp::NotIn => " not in ", | ||
CmpOp::Is => " is ", | ||
CmpOp::IsNot => " is not ", | ||
}); | ||
|
||
// Add the right side of the comparison. | ||
contents.push_str( | ||
locator.slice( | ||
parenthesized_range( | ||
comparator.into(), | ||
parent, | ||
comment_ranges, | ||
locator.contents(), | ||
) | ||
.unwrap_or(comparator.range()), | ||
), | ||
); | ||
} | ||
|
||
contents | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
132 changes: 132 additions & 0 deletions
132
crates/ruff_linter/src/rules/refurb/rules/single_item_membership_test.rs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation}; | ||
use ruff_macros::{derive_message_formats, violation}; | ||
use ruff_python_ast::helpers::generate_comparison; | ||
use ruff_python_ast::ExprConstant; | ||
use ruff_python_ast::{CmpOp, Constant, Expr}; | ||
use ruff_text_size::Ranged; | ||
|
||
use crate::checkers::ast::Checker; | ||
use crate::fix::edits::pad; | ||
use crate::registry::AsRule; | ||
|
||
/// ## What it does | ||
/// Checks for membership tests against single-item containers. | ||
/// | ||
/// ## Why is this bad? | ||
/// Performing a membership test against a container (like a `list` or `set`) | ||
/// with a single item is less readable and less efficient than comparing | ||
/// against the item directly. | ||
/// | ||
/// ## Example | ||
/// ```python | ||
/// 1 in [1] | ||
/// ``` | ||
/// | ||
/// Use instead: | ||
/// ```python | ||
/// 1 == 1 | ||
/// ``` | ||
/// | ||
/// ## References | ||
/// - [Python documentation: Comparisons](https://docs.python.org/3/reference/expressions.html#comparisons) | ||
/// - [Python documentation: Membership test operations](https://docs.python.org/3/reference/expressions.html#membership-test-operations) | ||
#[violation] | ||
pub struct SingleItemMembershipTest { | ||
membership_test: MembershipTest, | ||
} | ||
|
||
impl Violation for SingleItemMembershipTest { | ||
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; | ||
|
||
#[derive_message_formats] | ||
fn message(&self) -> String { | ||
format!("Membership test against single-item container") | ||
} | ||
|
||
fn fix_title(&self) -> Option<String> { | ||
let SingleItemMembershipTest { membership_test } = self; | ||
match membership_test { | ||
MembershipTest::In => Some("Convert to equality test".to_string()), | ||
MembershipTest::NotIn => Some("Convert to inequality test".to_string()), | ||
} | ||
} | ||
} | ||
|
||
/// FURB171 | ||
pub(crate) fn single_item_membership_test( | ||
checker: &mut Checker, | ||
expr: &Expr, | ||
left: &Expr, | ||
ops: &[CmpOp], | ||
comparators: &[Expr], | ||
) { | ||
let ([op], [right]) = (ops, comparators) else { | ||
return; | ||
}; | ||
|
||
// Ensure that the comparison is a membership test. | ||
let membership_test = match op { | ||
CmpOp::In => MembershipTest::In, | ||
CmpOp::NotIn => MembershipTest::NotIn, | ||
_ => return, | ||
}; | ||
|
||
// Check if the right-hand side is a single-item object. | ||
let Some(item) = single_item(right) else { | ||
return; | ||
}; | ||
|
||
let mut diagnostic = | ||
Diagnostic::new(SingleItemMembershipTest { membership_test }, expr.range()); | ||
if checker.patch(diagnostic.kind.rule()) { | ||
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement( | ||
pad( | ||
generate_comparison( | ||
left, | ||
&[membership_test.replacement_op()], | ||
&[item.clone()], | ||
expr.into(), | ||
checker.indexer().comment_ranges(), | ||
checker.locator(), | ||
), | ||
expr.range(), | ||
checker.locator(), | ||
), | ||
expr.range(), | ||
))); | ||
} | ||
checker.diagnostics.push(diagnostic); | ||
} | ||
|
||
/// Return the single item wrapped in Some if the expression contains a single | ||
/// item, otherwise return None. | ||
fn single_item(expr: &Expr) -> Option<&Expr> { | ||
match expr { | ||
Expr::List(list) if list.elts.len() == 1 => Some(&list.elts[0]), | ||
Expr::Tuple(tuple) if tuple.elts.len() == 1 => Some(&tuple.elts[0]), | ||
Expr::Set(set) if set.elts.len() == 1 => Some(&set.elts[0]), | ||
string_expr @ Expr::Constant(ExprConstant { | ||
value: Constant::Str(string), | ||
.. | ||
}) if string.chars().count() == 1 => Some(string_expr), | ||
_ => None, | ||
} | ||
} | ||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
enum MembershipTest { | ||
/// Ex) `1 in [1]` | ||
In, | ||
/// Ex) `1 not in [1]` | ||
NotIn, | ||
} | ||
|
||
impl MembershipTest { | ||
/// Returns the replacement comparison operator for this membership test. | ||
fn replacement_op(self) -> CmpOp { | ||
match self { | ||
MembershipTest::In => CmpOp::Eq, | ||
MembershipTest::NotIn => CmpOp::NotEq, | ||
} | ||
} | ||
} |
Oops, something went wrong.