Skip to content

Commit e373352

Browse files
committed
Implement static_join_to_fstring
1 parent 776b435 commit e373352

File tree

8 files changed

+179
-0
lines changed

8 files changed

+179
-0
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
a = "Hello"
2+
msg1 = " ".join([a, " World"])
3+
msg2 = "".join(["Finally, ", a, " World"])
4+
msg3 = "x".join(("1", "2", "3"))
5+
msg4 = "x".join({"4", "5", "yee"})
6+
msg5 = "y".join([1, 2, 3]) # Should be transformed
7+
msg6 = a.join(["1", "2", "3"]) # Should not be transformed (not a static joiner)
8+
msg7 = "a".join(a) # Should not be transformed (not a static joinee)
9+
msg8 = "a".join([a, a, *a]) # Should not be transformed (not a static length)
10+

crates/ruff/src/checkers/ast/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2436,13 +2436,21 @@ where
24362436
// pyupgrade
24372437
Rule::FormatLiterals,
24382438
Rule::FString,
2439+
// flynt
2440+
Rule::StaticJoinToFString,
24392441
]) {
24402442
if let ExprKind::Attribute { value, attr, .. } = &func.node {
24412443
if let ExprKind::Constant {
24422444
value: Constant::Str(value),
24432445
..
24442446
} = &value.node
24452447
{
2448+
if attr == "join" {
2449+
// "...".join(...) call
2450+
if self.settings.rules.enabled(Rule::StaticJoinToFString) {
2451+
flynt::rules::static_join_to_fstring(self, expr, value);
2452+
}
2453+
}
24462454
if attr == "format" {
24472455
// "...".format(...) call
24482456
let location = expr.range();

crates/ruff/src/codes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<Rule> {
733733

734734
// flynt
735735
(Flynt, "001") => Rule::StringConcatenationToFString,
736+
(Flynt, "002") => Rule::StaticJoinToFString,
736737

737738
_ => return None,
738739
})

crates/ruff/src/registry.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,7 @@ ruff_macros::register_rules!(
667667
rules::flake8_django::rules::DjangoNonLeadingReceiverDecorator,
668668
// flynt
669669
rules::flynt::rules::StringConcatenationToFString,
670+
rules::flynt::rules::StaticJoinToFString,
670671
);
671672

672673
pub trait AsRule {

crates/ruff/src/rules/flynt/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod tests {
1212
use test_case::test_case;
1313

1414
#[test_case(Rule::StringConcatenationToFString, Path::new("FLY001.py"); "FLY001")]
15+
#[test_case(Rule::StaticJoinToFString, Path::new("FLY002.py"); "FLY002")]
1516
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
1617
let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy());
1718
let diagnostics = test_path(
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
mod concat_to_fstring;
2+
mod static_join_to_fstring;
23

34
pub use concat_to_fstring::{string_concatenation_to_fstring, StringConcatenationToFString};
5+
pub use static_join_to_fstring::{static_join_to_fstring, StaticJoinToFString};
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
use rustpython_parser::ast::{Expr, ExprKind};
2+
3+
use ruff_diagnostics::{AutofixKind, Diagnostic, Edit, Violation};
4+
use ruff_macros::{derive_message_formats, violation};
5+
use ruff_python_ast::helpers::{create_expr, has_comments, unparse_expr};
6+
7+
use crate::checkers::ast::Checker;
8+
use crate::registry::AsRule;
9+
use crate::rules::flynt::helpers;
10+
use crate::rules::flynt::helpers::to_constant_string;
11+
12+
#[violation]
13+
pub struct StaticJoinToFString {
14+
pub expr: String,
15+
pub fixable: bool,
16+
}
17+
18+
impl Violation for StaticJoinToFString {
19+
const AUTOFIX: AutofixKind = AutofixKind::Sometimes;
20+
21+
#[derive_message_formats]
22+
fn message(&self) -> String {
23+
let StaticJoinToFString { expr, .. } = self;
24+
format!("Consider `{expr}` instead of string join")
25+
}
26+
27+
fn autofix_title_formatter(&self) -> Option<fn(&Self) -> String> {
28+
self.fixable
29+
.then_some(|StaticJoinToFString { expr, .. }| format!("Replace with `{expr}`"))
30+
}
31+
}
32+
33+
fn is_static_length(elts: &[Expr]) -> bool {
34+
elts.iter()
35+
.all(|e| !matches!(e.node, ExprKind::Starred { .. }))
36+
}
37+
38+
pub fn static_join_to_fstring(checker: &mut Checker, expr: &Expr, joiner: &str) {
39+
let ExprKind::Call {
40+
func: _,
41+
args,
42+
keywords,
43+
} = &expr.node else {
44+
return;
45+
};
46+
if !keywords.is_empty() || args.len() != 1 {
47+
// Not a string join call we know of, this...
48+
return;
49+
}
50+
let joinees = match &args[0].node {
51+
ExprKind::List { elts, .. } if is_static_length(elts) => elts,
52+
ExprKind::Tuple { elts, .. } if is_static_length(elts) => elts,
53+
_ => return,
54+
};
55+
let mut fstring_elems = Vec::with_capacity(joinees.len() * 2);
56+
for (i, expr) in joinees.iter().enumerate() {
57+
let elem = helpers::to_fstring_elem(expr.clone());
58+
if i != 0 {
59+
fstring_elems.push(to_constant_string(joiner));
60+
}
61+
fstring_elems.push(elem);
62+
}
63+
let new_expr = create_expr(ExprKind::JoinedStr {
64+
values: fstring_elems,
65+
});
66+
67+
let contents = unparse_expr(&new_expr, checker.stylist);
68+
let fixable = !has_comments(expr, checker.locator);
69+
70+
let mut diagnostic = Diagnostic::new(
71+
StaticJoinToFString {
72+
expr: contents.clone(),
73+
fixable,
74+
},
75+
expr.range(),
76+
);
77+
if checker.patch(diagnostic.kind.rule()) {
78+
if fixable {
79+
diagnostic.set_fix(Edit::range_replacement(contents, expr.range()));
80+
}
81+
}
82+
checker.diagnostics.push(diagnostic);
83+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
source: crates/ruff/src/rules/flynt/mod.rs
3+
---
4+
FLY002.py:2:8: FLY002 [*] Consider `f"{a} World"` instead of string join
5+
|
6+
2 | a = "Hello"
7+
3 | msg1 = " ".join([a, " World"])
8+
| ^^^^^^^^^^^^^^^^^^^^^^^ FLY002
9+
4 | msg2 = "".join(["Finally, ", a, " World"])
10+
5 | msg3 = "x".join(("1", "2", "3"))
11+
|
12+
= help: Replace with `f"{a} World"`
13+
14+
Suggested fix
15+
1 1 | a = "Hello"
16+
2 |-msg1 = " ".join([a, " World"])
17+
2 |+msg1 = f"{a} World"
18+
3 3 | msg2 = "".join(["Finally, ", a, " World"])
19+
4 4 | msg3 = "x".join(("1", "2", "3"))
20+
5 5 | msg4 = "x".join({"4", "5", "yee"})
21+
22+
FLY002.py:3:8: FLY002 [*] Consider `f"Finally, {a} World"` instead of string join
23+
|
24+
3 | a = "Hello"
25+
4 | msg1 = " ".join([a, " World"])
26+
5 | msg2 = "".join(["Finally, ", a, " World"])
27+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FLY002
28+
6 | msg3 = "x".join(("1", "2", "3"))
29+
7 | msg4 = "x".join({"4", "5", "yee"})
30+
|
31+
= help: Replace with `f"Finally, {a} World"`
32+
33+
Suggested fix
34+
1 1 | a = "Hello"
35+
2 2 | msg1 = " ".join([a, " World"])
36+
3 |-msg2 = "".join(["Finally, ", a, " World"])
37+
3 |+msg2 = f"Finally, {a} World"
38+
4 4 | msg3 = "x".join(("1", "2", "3"))
39+
5 5 | msg4 = "x".join({"4", "5", "yee"})
40+
6 6 | msg5 = "y".join([1, 2, 3]) # Should be transformed
41+
42+
FLY002.py:4:8: FLY002 [*] Consider `f"1x2x3"` instead of string join
43+
|
44+
4 | msg1 = " ".join([a, " World"])
45+
5 | msg2 = "".join(["Finally, ", a, " World"])
46+
6 | msg3 = "x".join(("1", "2", "3"))
47+
| ^^^^^^^^^^^^^^^^^^^^^^^^^ FLY002
48+
7 | msg4 = "x".join({"4", "5", "yee"})
49+
8 | msg5 = "y".join([1, 2, 3]) # Should be transformed
50+
|
51+
= help: Replace with `f"1x2x3"`
52+
53+
Suggested fix
54+
1 1 | a = "Hello"
55+
2 2 | msg1 = " ".join([a, " World"])
56+
3 3 | msg2 = "".join(["Finally, ", a, " World"])
57+
4 |-msg3 = "x".join(("1", "2", "3"))
58+
4 |+msg3 = f"1x2x3"
59+
5 5 | msg4 = "x".join({"4", "5", "yee"})
60+
6 6 | msg5 = "y".join([1, 2, 3]) # Should be transformed
61+
7 7 | msg6 = a.join(["1", "2", "3"]) # Should not be transformed (not a static joiner)
62+
63+
FLY002.py:6:8: FLY002 Consider `f"{1}y{2}y{3}"` instead of string join
64+
|
65+
6 | msg3 = "x".join(("1", "2", "3"))
66+
7 | msg4 = "x".join({"4", "5", "yee"})
67+
8 | msg5 = "y".join([1, 2, 3]) # Should be transformed
68+
| ^^^^^^^^^^^^^^^^^^^ FLY002
69+
9 | msg6 = a.join(["1", "2", "3"]) # Should not be transformed (not a static joiner)
70+
10 | msg7 = "a".join(a) # Should not be transformed (not a static joinee)
71+
|
72+
73+

0 commit comments

Comments
 (0)