Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[refurb] New rule to suggest min/max over sorted() (FURB192) #10868

Merged
merged 32 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9320b66
init
ottaviohartman Apr 2, 2024
c3813a3
first stab
ottaviohartman Apr 4, 2024
0e94732
impl nearly done
ottaviohartman Apr 4, 2024
129ef99
impl nearly done
ottaviohartman Apr 4, 2024
928b3bc
compiling
ottaviohartman Apr 5, 2024
8e84731
refactor
ottaviohartman Apr 5, 2024
5be22a4
is_reversed
ottaviohartman Apr 5, 2024
5e073fd
simplification
ottaviohartman Apr 7, 2024
6d73582
update py
ottaviohartman Apr 9, 2024
8af13c6
working
ottaviohartman Apr 9, 2024
3519d24
fmt
ottaviohartman Apr 9, 2024
cf59390
clippy
ottaviohartman Apr 10, 2024
b0f0384
reverse edge case
ottaviohartman Apr 10, 2024
2140e17
basic cases working
ottaviohartman Apr 10, 2024
b212e87
updated rules
ottaviohartman Apr 10, 2024
e10e15a
add snap
ottaviohartman Apr 10, 2024
2dcd134
Merge branch 'main' of github.com:astral-sh/ruff into thartman/furb192
ottaviohartman Apr 10, 2024
bec8502
generate schema
ottaviohartman Apr 10, 2024
7fe30b4
add docs
ottaviohartman Apr 11, 2024
22fae02
another test
ottaviohartman Apr 11, 2024
b73e47c
cleanup
ottaviohartman Apr 11, 2024
f8df98a
another test
ottaviohartman Apr 11, 2024
2b3db42
fix list expr
ottaviohartman Apr 16, 2024
02f754e
Merge branch 'main' of github.com:astral-sh/ruff into thartman/furb192
ottaviohartman Apr 16, 2024
a5be688
crashign
ottaviohartman Apr 16, 2024
049dd85
increase size of bitset array
ottaviohartman Apr 16, 2024
eb6462b
clippy
ottaviohartman Apr 16, 2024
e804774
Merge branch 'main' of github.com:astral-sh/ruff into thartman/furb192
ottaviohartman Apr 18, 2024
850faaa
rename is_builtin
ottaviohartman Apr 18, 2024
e659492
Merge branch 'main' into thartman/furb192
charliermarsh Apr 23, 2024
96a5536
Rebase; reformat
charliermarsh Apr 23, 2024
da36bfc
Minor tweaks
charliermarsh Apr 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
39 changes: 39 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/refurb/FURB192.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Errors

sorted(l)[0]

sorted(l)[-1]

sorted(l, reverse=True)[0]

sorted(l, reverse=True)[-1]

sorted(l, reverse=False)[-1]

sorted(l, key=lambda x: x)[0]

sorted(l, key=key_fn, reverse=True)[-1]

sorted(l, key=key_fn)[0]

# Non-errors

sorted(l, reverse=foo())[0]

sorted([1, 2, 3])[0]
ottaviohartman marked this conversation as resolved.
Show resolved Hide resolved

sorted(l)[1]

sorted(get_list())[1]

sorted()[0]

sorted(l)[1]

sorted(l)[-2]

b = True

sorted(l, reverse=b)[0]
0
sorted(l, invalid_kwarg=True)[0]
3 changes: 3 additions & 0 deletions crates/ruff_linter/src/checkers/ast/analyze/expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
if checker.enabled(Rule::PotentialIndexError) {
pylint::rules::potential_index_error(checker, value, slice);
}
if checker.enabled(Rule::SortedMinMax) {
refurb::rules::sorted_min_max(checker, subscript);
}

pandas_vet::rules::subscript(checker, value, expr);
}
Expand Down
1 change: 1 addition & 0 deletions crates/ruff_linter/src/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Refurb, "180") => (RuleGroup::Preview, rules::refurb::rules::MetaClassABCMeta),
(Refurb, "181") => (RuleGroup::Preview, rules::refurb::rules::HashlibDigestHex),
(Refurb, "187") => (RuleGroup::Preview, rules::refurb::rules::ListReverseCopy),
(Refurb, "192") => (RuleGroup::Preview, rules::refurb::rules::SortedMinMax),

// flake8-logging
(Flake8Logging, "001") => (RuleGroup::Stable, rules::flake8_logging::rules::DirectLoggerInstantiation),
Expand Down
1 change: 1 addition & 0 deletions crates/ruff_linter/src/rules/refurb/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ mod tests {
#[test_case(Rule::MetaClassABCMeta, Path::new("FURB180.py"))]
#[test_case(Rule::HashlibDigestHex, Path::new("FURB181.py"))]
#[test_case(Rule::ListReverseCopy, Path::new("FURB187.py"))]
#[test_case(Rule::SortedMinMax, Path::new("FURB192.py"))]
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy());
let diagnostics = test_path(
Expand Down
2 changes: 2 additions & 0 deletions crates/ruff_linter/src/rules/refurb/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub(crate) use reimplemented_starmap::*;
pub(crate) use repeated_append::*;
pub(crate) use single_item_membership_test::*;
pub(crate) use slice_copy::*;
pub(crate) use sorted_min_max::*;
pub(crate) use type_none_comparison::*;
pub(crate) use unnecessary_enumerate::*;
pub(crate) use unnecessary_from_float::*;
Expand Down Expand Up @@ -49,6 +50,7 @@ mod reimplemented_starmap;
mod repeated_append;
mod single_item_membership_test;
mod slice_copy;
mod sorted_min_max;
mod type_none_comparison;
mod unnecessary_enumerate;
mod unnecessary_from_float;
Expand Down
201 changes: 201 additions & 0 deletions crates/ruff_linter/src/rules/refurb/rules/sorted_min_max.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
use ruff_diagnostics::Diagnostic;
use ruff_diagnostics::Edit;
use ruff_diagnostics::Fix;
use ruff_diagnostics::FixAvailability;
use ruff_diagnostics::Violation;
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::{self as ast, Expr};

use crate::checkers::ast::Checker;
use ruff_python_ast::Number;
use ruff_text_size::Ranged;

/// ## What it does
/// Checks for uses of `sorted()` to get the min and max values of a list.
///
/// ## Why is this bad?
/// Using `sorted()` to get the min and max values of a list is inefficient.
///
/// ## Example
/// ```python
/// nums = [3, 1, 4, 1, 5]
/// lowest = sorted(nums)[0]
/// highest = sorted(nums)[-1]
/// highest = sorted(nums, reverse=True)[0]
/// ```
///
/// Use instead:
/// ```python
/// nums = [3, 1, 4, 1, 5]
/// lowest = min(nums)
/// highest = max(nums)
/// ```
///
/// ## Note
///
/// If the original statement uses `reverse=True`, the `min` and `max` replacement will not
/// be equivalent if the intended result is to get a non-stable min and max.
///
/// In other words, `sorted(data, key=itemgetter(0), reverse=True)[0]` is not a stable min,
/// but `min(data, key=itemgetter(0))` is a stable min.
///
/// ## References
/// - [Python documentation: `min`](https://docs.python.org/3/library/functions.html#min)
/// - [Python documentation: `max`](https://docs.python.org/3/library/functions.html#max)
#[violation]
pub struct SortedMinMax;

impl Violation for SortedMinMax {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Always;

#[derive_message_formats]
fn message(&self) -> String {
format!("Prefer `min` or `max` over `sorted()` to get the min and max values of a list")
}

fn fix_title(&self) -> Option<String> {
Some(format!("Replace with `min` or `max`"))
}
}

/// FURB192
pub(crate) fn sorted_min_max(checker: &mut Checker, subscript: &ast::ExprSubscript) {
if subscript.ctx.is_store() || subscript.ctx.is_del() {
return;
}

let ast::ExprSubscript { slice, value, .. } = &subscript;

// Early return if index is not unary or a number literal.
if !(slice.is_number_literal_expr() || slice.is_unary_op_expr()) {
return;
}

let Expr::Call(ast::ExprCall {
func, arguments, ..
}) = value.as_ref()
else {
return;
};

// Check if the value is a call to `sorted()`.
if !matches!(func.as_ref(), Expr::Name(name) if name.id == "sorted" && checker.semantic().is_builtin(name.id.as_str()))
{
return;
};

let index = match slice.as_ref() {
// [0]
Expr::NumberLiteral(ast::ExprNumberLiteral {
value: Number::Int(index),
..
}) if *index == 0 => Index::First,

// [-1]
Expr::UnaryOp(ast::ExprUnaryOp {
op: ast::UnaryOp::USub,
operand,
..
}) => {
match operand.as_ref() {
// [-1]
Expr::NumberLiteral(ast::ExprNumberLiteral {
value: Number::Int(index),
..
}) if *index == 1 => Index::Last,
_ => return,
}
}
_ => return,
};

let mut reverse_keyword = None;
let mut key_keyword_expr = None;

// Check if the call to `sorted()` has the `reverse` and `key` keywords.
for keyword in arguments.keywords.iter() {
if reverse_keyword.is_none()
&& keyword
.arg
.as_ref()
.map_or(false, |arg| arg.as_str() == "reverse")
{
reverse_keyword = Some(keyword);
} else if key_keyword_expr.is_none()
&& keyword
.arg
.as_ref()
.map_or(false, |arg| arg.as_str() == "key")
{
key_keyword_expr = Some(&keyword.value);
} else {
// If unexpected keyword is found, return.
return;
}
}

let is_reversed = if let Some(reverse_keyword) = reverse_keyword {
match reverse_keyword.value.as_boolean_literal_expr() {
Some(ast::ExprBooleanLiteral { value, .. }) => *value,
// If the value is not a boolean literal, we can't determine if it is reversed.
_ => return,
}
} else {
// No reverse keyword, so it is not reversed.
false
};

let min_max = match (index, is_reversed) {
(Index::First, false) => MinMax::Min,
(Index::First, true) => MinMax::Max,
(Index::Last, false) => MinMax::Max,
(Index::Last, true) => MinMax::Min,
};

// Get the list expression.
let Some(Expr::Name(list_expr)) = arguments.args.first() else {
return;
};

let replacement = if let Some(key) = &key_keyword_expr {
format!(
"{}({}, key={})",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a cleaner or more canonical way to generate this?

min_max.as_str(),
list_expr.id,
checker.generator().expr(key)
)
} else {
format!("{}({})", min_max.as_str(), list_expr.id)
};
ottaviohartman marked this conversation as resolved.
Show resolved Hide resolved

let mut diagnostic = Diagnostic::new(SortedMinMax, subscript.range());
diagnostic.set_fix(Fix::safe_edit(Edit::replacement(
replacement,
subscript.start(),
subscript.end(),
)));
checker.diagnostics.push(diagnostic);
}

#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum MinMax {
Min,
Max,
}

impl MinMax {
fn as_str(self) -> &'static str {
match self {
Self::Min => "min",
Self::Max => "max",
}
}
}

#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum Index {
// 0
First,
// -1
Last,
}