Skip to content

Commit

Permalink
Add RUF005 "unpack instead of concatenating" check
Browse files Browse the repository at this point in the history
* Guard against complex splattees
* Wrap tuples in parens
  • Loading branch information
akx committed Jan 19, 2023
1 parent a91ebfb commit d9dcf92
Show file tree
Hide file tree
Showing 10 changed files with 318 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,7 @@ For more, see [flake8-no-pep420](https://pypi.org/project/flake8-no-pep420/2.3.0
| RUF002 | AmbiguousUnicodeCharacterDocstring | Docstring contains ambiguous unicode character '{confusable}' (did you mean '{representant}'?) | 🛠 |
| RUF003 | AmbiguousUnicodeCharacterComment | Comment contains ambiguous unicode character '{confusable}' (did you mean '{representant}'?) | 🛠 |
| RUF004 | KeywordArgumentBeforeStarArgument | Keyword argument `{name}` must come after starred arguments | |
| RUF005 | UnpackInsteadOfConcatenatingToCollectionLiteral | Consider `{expr}` instead of concatenation | |
| RUF100 | UnusedNOQA | Unused blanket `noqa` directive | 🛠 |

<!-- End auto-generated sections. -->
Expand Down
22 changes: 22 additions & 0 deletions resources/test/fixtures/ruff/RUF005.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
class Fun:
words = ("how", "fun!")

def yay(self):
return self.words

yay = Fun().yay

foo = [4, 5, 6]
bar = [1, 2, 3] + foo
zoob = tuple(bar)
quux = (7, 8, 9) + zoob
spam = quux + (10, 11, 12)
spom = list(spam)
eggs = spom + [13, 14, 15]
elatement = ("we all say", ) + yay()
excitement = ("we all think", ) + Fun().yay()
astonishment = ("we all feel", ) + Fun.words

chain = ['a', 'b', 'c'] + eggs + list(('yes', 'no', 'pants') + zoob)

baz = () + zoob
1 change: 1 addition & 0 deletions ruff.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1631,6 +1631,7 @@
"RUF002",
"RUF003",
"RUF004",
"RUF005",
"RUF1",
"RUF10",
"RUF100",
Expand Down
7 changes: 7 additions & 0 deletions src/checkers/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2722,6 +2722,13 @@ where
self.diagnostics.push(diagnostic);
}
}
if self
.settings
.rules
.enabled(&Rule::UnpackInsteadOfConcatenatingToCollectionLiteral)
{
ruff::rules::unpack_instead_of_concatenating_to_collection_literal(self, expr);
}
}
ExprKind::UnaryOp { op, operand } => {
let check_not_in = self.settings.rules.enabled(&Rule::NotInTest);
Expand Down
1 change: 1 addition & 0 deletions src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ ruff_macros::define_rule_mapping!(
RUF002 => violations::AmbiguousUnicodeCharacterDocstring,
RUF003 => violations::AmbiguousUnicodeCharacterComment,
RUF004 => violations::KeywordArgumentBeforeStarArgument,
RUF005 => violations::UnpackInsteadOfConcatenatingToCollectionLiteral,
RUF100 => violations::UnusedNOQA,
);

Expand Down
1 change: 1 addition & 0 deletions src/rules/ruff/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mod tests {
use crate::registry::Rule;
use crate::settings;
#[test_case(Rule::KeywordArgumentBeforeStarArgument, Path::new("RUF004.py"); "RUF004")]
#[test_case(Rule::UnpackInsteadOfConcatenatingToCollectionLiteral, Path::new("RUF005.py"); "RUF005")]
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!("{}_{}", rule_code.code(), path.to_string_lossy());
let diagnostics = test_path(
Expand Down
3 changes: 3 additions & 0 deletions src/rules/ruff/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ use crate::registry::Diagnostic;
use crate::violations;

mod ambiguous_unicode_character;
mod unpack_instead_of_concatenating_to_collection_literal;

pub use ambiguous_unicode_character::ambiguous_unicode_character;
pub use unpack_instead_of_concatenating_to_collection_literal::unpack_instead_of_concatenating_to_collection_literal;

#[derive(Clone, Copy)]
pub enum Context {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
use rustpython_ast::{Expr, ExprContext, ExprKind, Operator};

use crate::ast::helpers::{create_expr, unparse_expr};
use crate::ast::types::Range;
use crate::checkers::ast::Checker;
use crate::fix::Fix;
use crate::registry::Diagnostic;
use crate::violations;

fn make_splat_elts(
splat_element: &Expr,
other_elements: &[Expr],
splat_at_left: bool,
) -> Vec<Expr> {
let mut new_elts = other_elements.to_owned();
let splat = create_expr(ExprKind::Starred {
value: Box::from(splat_element.clone()),
ctx: ExprContext::Load,
});
if splat_at_left {
new_elts.insert(0, splat);
} else {
new_elts.push(splat);
}
new_elts
}

#[derive(Debug)]
enum Kind {
List,
Tuple,
}

/// RUF005
/// This suggestion could be unsafe if the non-literal expression in the
/// expression has overridden the `__add__` (or `__radd__`) magic methods.
pub fn unpack_instead_of_concatenating_to_collection_literal(checker: &mut Checker, expr: &Expr) {
let ExprKind::BinOp { op, left, right } = &expr.node else {
return;
};
if !matches!(op, Operator::Add) {
return;
}

// Figure out which way the splat is, and what the kind of the collection is.
let (kind, splat_element, other_elements, splat_at_left, ctx) = match (&left.node, &right.node)
{
(ExprKind::List { elts: l_elts, ctx }, _) => (Kind::List, right, l_elts, false, ctx),
(ExprKind::Tuple { elts: l_elts, ctx }, _) => (Kind::Tuple, right, l_elts, false, ctx),
(_, ExprKind::List { elts: r_elts, ctx }) => (Kind::List, left, r_elts, true, ctx),
(_, ExprKind::Tuple { elts: r_elts, ctx }) => (Kind::Tuple, left, r_elts, true, ctx),
_ => return,
};

// We'll be a bit conservative here; only calls, names and attribute accesses
// will be considered as splat elements.
if !matches!(
splat_element.node,
ExprKind::Call { .. } | ExprKind::Name { .. } | ExprKind::Attribute { .. }
) {
return;
}

let new_expr = match kind {
Kind::List => create_expr(ExprKind::List {
elts: make_splat_elts(splat_element, other_elements, splat_at_left),
ctx: ctx.clone(),
}),
Kind::Tuple => create_expr(ExprKind::Tuple {
elts: make_splat_elts(splat_element, other_elements, splat_at_left),
ctx: ctx.clone(),
}),
};

let mut new_expr_string = unparse_expr(&new_expr, checker.stylist);

new_expr_string = match kind {
// Wrap the new expression in parentheses if it was a tuple
Kind::Tuple => format!("({new_expr_string})"),
Kind::List => new_expr_string,
};

let mut diagnostic = Diagnostic::new(
violations::UnpackInsteadOfConcatenatingToCollectionLiteral(new_expr_string.clone()),
Range::from_located(expr),
);
if checker.patch(diagnostic.kind.rule()) {
diagnostic.amend(Fix::replacement(
new_expr_string,
expr.location,
expr.end_location.unwrap(),
));
}
checker.diagnostics.push(diagnostic);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
---
source: src/rules/ruff/mod.rs
expression: diagnostics
---
- kind:
UnpackInsteadOfConcatenatingToCollectionLiteral: "[1, 2, 3, *foo]"
location:
row: 10
column: 6
end_location:
row: 10
column: 21
fix:
content: "[1, 2, 3, *foo]"
location:
row: 10
column: 6
end_location:
row: 10
column: 21
parent: ~
- kind:
UnpackInsteadOfConcatenatingToCollectionLiteral: "(7, 8, 9, *zoob)"
location:
row: 12
column: 7
end_location:
row: 12
column: 23
fix:
content: "(7, 8, 9, *zoob)"
location:
row: 12
column: 7
end_location:
row: 12
column: 23
parent: ~
- kind:
UnpackInsteadOfConcatenatingToCollectionLiteral: "(*quux, 10, 11, 12)"
location:
row: 13
column: 7
end_location:
row: 13
column: 26
fix:
content: "(*quux, 10, 11, 12)"
location:
row: 13
column: 7
end_location:
row: 13
column: 26
parent: ~
- kind:
UnpackInsteadOfConcatenatingToCollectionLiteral: "[*spom, 13, 14, 15]"
location:
row: 15
column: 7
end_location:
row: 15
column: 26
fix:
content: "[*spom, 13, 14, 15]"
location:
row: 15
column: 7
end_location:
row: 15
column: 26
parent: ~
- kind:
UnpackInsteadOfConcatenatingToCollectionLiteral: "(\"we all say\", *yay())"
location:
row: 16
column: 12
end_location:
row: 16
column: 36
fix:
content: "(\"we all say\", *yay())"
location:
row: 16
column: 12
end_location:
row: 16
column: 36
parent: ~
- kind:
UnpackInsteadOfConcatenatingToCollectionLiteral: "(\"we all think\", *Fun().yay())"
location:
row: 17
column: 13
end_location:
row: 17
column: 45
fix:
content: "(\"we all think\", *Fun().yay())"
location:
row: 17
column: 13
end_location:
row: 17
column: 45
parent: ~
- kind:
UnpackInsteadOfConcatenatingToCollectionLiteral: "(\"we all feel\", *Fun.words)"
location:
row: 18
column: 15
end_location:
row: 18
column: 44
fix:
content: "(\"we all feel\", *Fun.words)"
location:
row: 18
column: 15
end_location:
row: 18
column: 44
parent: ~
- kind:
UnpackInsteadOfConcatenatingToCollectionLiteral: "[\"a\", \"b\", \"c\", *eggs]"
location:
row: 20
column: 8
end_location:
row: 20
column: 30
fix:
content: "[\"a\", \"b\", \"c\", *eggs]"
location:
row: 20
column: 8
end_location:
row: 20
column: 30
parent: ~
- kind:
UnpackInsteadOfConcatenatingToCollectionLiteral: "(\"yes\", \"no\", \"pants\", *zoob)"
location:
row: 20
column: 38
end_location:
row: 20
column: 67
fix:
content: "(\"yes\", \"no\", \"pants\", *zoob)"
location:
row: 20
column: 38
end_location:
row: 20
column: 67
parent: ~
- kind:
UnpackInsteadOfConcatenatingToCollectionLiteral: "(*zoob,)"
location:
row: 22
column: 6
end_location:
row: 22
column: 15
fix:
content: "(*zoob,)"
location:
row: 22
column: 6
end_location:
row: 22
column: 15
parent: ~

12 changes: 12 additions & 0 deletions src/violations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2652,6 +2652,18 @@ impl AlwaysAutofixableViolation for DictGetWithDefault {
format!("Replace with `{contents}`")
}
}

define_violation!(
pub struct UnpackInsteadOfConcatenatingToCollectionLiteral(pub String);
);
impl Violation for UnpackInsteadOfConcatenatingToCollectionLiteral {
#[derive_message_formats]
fn message(&self) -> String {
let UnpackInsteadOfConcatenatingToCollectionLiteral(expr) = self;
format!("Consider `{expr}` instead of concatenation")
}
}

// pyupgrade

define_violation!(
Expand Down

0 comments on commit d9dcf92

Please sign in to comment.