Skip to content

Commit eb4ed24

Browse files
authored
[flake8-simplify] Implement SIM911 (#9460)
## Summary Closes #9319, implements the [`SIM911` rule from `flake8-simplify`](MartinThoma/flake8-simplify#183). #### Note I wasn't sure whether or not to include ```rs if checker.settings.preview.is_disabled() { return; } ``` at the beginning of the function with violation logic if the rule's already declared as part of `RuleGroup::Preview`. I've seen both variants, so I'd appreciate some feedback on that :)
1 parent f192c72 commit eb4ed24

File tree

6 files changed

+160
-0
lines changed

6 files changed

+160
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
def foo(d: dict[str, str]) -> None:
2+
for k, v in zip(d.keys(), d.values()): # SIM911
3+
...
4+
5+
for k, v in zip(d.keys(), d.values(), strict=True): # SIM911
6+
...
7+
8+
for k, v in zip(d.keys(), d.values(), struct=True): # OK
9+
...
10+
11+
12+
d1 = d2 = {}
13+
14+
for k, v in zip(d1.keys(), d2.values()): # OK
15+
...
16+
17+
for k, v in zip(d1.items(), d2.values()): # OK
18+
...
19+
20+
for k, v in zip(d2.keys(), d2.values()): # SIM911
21+
...
22+
23+
items = zip(x.keys(), x.values()) # OK

crates/ruff_linter/src/checkers/ast/analyze/expression.rs

+3
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,9 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
863863
if checker.enabled(Rule::DictGetWithNoneDefault) {
864864
flake8_simplify::rules::dict_get_with_none_default(checker, expr);
865865
}
866+
if checker.enabled(Rule::ZipDictKeysAndValues) {
867+
flake8_simplify::rules::zip_dict_keys_and_values(checker, call);
868+
}
866869
if checker.any_enabled(&[
867870
Rule::OsPathAbspath,
868871
Rule::OsChmod,

crates/ruff_linter/src/codes.rs

+1
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
472472
(Flake8Simplify, "300") => (RuleGroup::Stable, rules::flake8_simplify::rules::YodaConditions),
473473
(Flake8Simplify, "401") => (RuleGroup::Stable, rules::flake8_simplify::rules::IfElseBlockInsteadOfDictGet),
474474
(Flake8Simplify, "910") => (RuleGroup::Stable, rules::flake8_simplify::rules::DictGetWithNoneDefault),
475+
(Flake8Simplify, "911") => (RuleGroup::Preview, rules::flake8_simplify::rules::ZipDictKeysAndValues),
475476

476477
// flake8-copyright
477478
#[allow(deprecated)]

crates/ruff_linter/src/rules/flake8_simplify/rules/mod.rs

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub(crate) use reimplemented_builtin::*;
1515
pub(crate) use return_in_try_except_finally::*;
1616
pub(crate) use suppressible_exception::*;
1717
pub(crate) use yoda_conditions::*;
18+
pub(crate) use zip_dict_keys_and_values::*;
1819

1920
mod ast_bool_op;
2021
mod ast_expr;
@@ -34,3 +35,4 @@ mod reimplemented_builtin;
3435
mod return_in_try_except_finally;
3536
mod suppressible_exception;
3637
mod yoda_conditions;
38+
mod zip_dict_keys_and_values;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
use ast::{ExprAttribute, ExprName, Identifier};
2+
use ruff_macros::{derive_message_formats, violation};
3+
use ruff_python_ast::{self as ast, Arguments, Expr, ExprCall};
4+
use ruff_text_size::Ranged;
5+
6+
use crate::{checkers::ast::Checker, fix::snippet::SourceCodeSnippet};
7+
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
8+
use ruff_python_semantic::analyze::typing::is_dict;
9+
10+
/// ## What it does
11+
/// Checks for use of `zip()` to iterate over keys and values of a dictionary at once.
12+
///
13+
/// ## Why is this bad?
14+
/// The `dict` type provides an `.items()` method which is faster and more readable.
15+
///
16+
/// ## Example
17+
/// ```python
18+
/// flag_stars = {"USA": 50, "Slovenia": 3, "Panama": 2, "Australia": 6}
19+
///
20+
/// for country, stars in zip(flag_stars.keys(), flag_stars.values()):
21+
/// print(f"{country}'s flag has {stars} stars.")
22+
/// ```
23+
///
24+
/// Use instead:
25+
/// ```python
26+
/// flag_stars = {"USA": 50, "Slovenia": 3, "Panama": 2, "Australia": 6}
27+
///
28+
/// for country, stars in flag_stars.items():
29+
/// print(f"{country}'s flag has {stars} stars.")
30+
/// ```
31+
///
32+
/// ## References
33+
/// - [Python documentation: `dict.items`](https://docs.python.org/3/library/stdtypes.html#dict.items)
34+
#[violation]
35+
pub struct ZipDictKeysAndValues {
36+
expected: SourceCodeSnippet,
37+
actual: SourceCodeSnippet,
38+
}
39+
40+
impl AlwaysFixableViolation for ZipDictKeysAndValues {
41+
#[derive_message_formats]
42+
fn message(&self) -> String {
43+
let ZipDictKeysAndValues { expected, actual } = self;
44+
if let (Some(expected), Some(actual)) = (expected.full_display(), actual.full_display()) {
45+
format!("Use `{expected}` instead of `{actual}`")
46+
} else {
47+
format!("Use `dict.items()` instead of `zip(dict.keys(), dict.values())`")
48+
}
49+
}
50+
51+
fn fix_title(&self) -> String {
52+
let ZipDictKeysAndValues { expected, actual } = self;
53+
if let (Some(expected), Some(actual)) = (expected.full_display(), actual.full_display()) {
54+
format!("Replace `{actual}` with `{expected}`")
55+
} else {
56+
"Replace `zip(dict.keys(), dict.values())` with `dict.items()`".to_string()
57+
}
58+
}
59+
}
60+
61+
/// SIM911
62+
pub(crate) fn zip_dict_keys_and_values(checker: &mut Checker, expr: &ExprCall) {
63+
let ExprCall {
64+
func,
65+
arguments: Arguments { args, keywords, .. },
66+
..
67+
} = expr;
68+
match &keywords[..] {
69+
[] => {}
70+
[ast::Keyword {
71+
arg: Some(name), ..
72+
}] if name.as_str() == "strict" => {}
73+
_ => return,
74+
};
75+
if matches!(func.as_ref(), Expr::Name(ExprName { id, .. }) if id != "zip") {
76+
return;
77+
}
78+
let [arg1, arg2] = &args[..] else {
79+
return;
80+
};
81+
let Some((var1, attr1)) = get_var_attr(arg1) else {
82+
return;
83+
};
84+
let Some((var2, attr2)) = get_var_attr(arg2) else {
85+
return;
86+
};
87+
if var1.id != var2.id || attr1 != "keys" || attr2 != "values" {
88+
return;
89+
}
90+
91+
let Some(binding) = checker
92+
.semantic()
93+
.only_binding(var1)
94+
.map(|id| checker.semantic().binding(id))
95+
else {
96+
return;
97+
};
98+
if !is_dict(binding, checker.semantic()) {
99+
return;
100+
}
101+
102+
let expected = format!("{}.items()", checker.locator().slice(var1));
103+
let actual = checker.locator().slice(expr);
104+
105+
let mut diagnostic = Diagnostic::new(
106+
ZipDictKeysAndValues {
107+
expected: SourceCodeSnippet::new(expected.clone()),
108+
actual: SourceCodeSnippet::from_str(actual),
109+
},
110+
expr.range(),
111+
);
112+
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement(
113+
expected,
114+
expr.range(),
115+
)));
116+
checker.diagnostics.push(diagnostic);
117+
}
118+
119+
fn get_var_attr(expr: &Expr) -> Option<(&ExprName, &Identifier)> {
120+
let Expr::Call(ast::ExprCall { func, .. }) = expr else {
121+
return None;
122+
};
123+
let Expr::Attribute(ExprAttribute { value, attr, .. }) = func.as_ref() else {
124+
return None;
125+
};
126+
let Expr::Name(var_name) = value.as_ref() else {
127+
return None;
128+
};
129+
Some((var_name, attr))
130+
}

ruff.schema.json

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)