Skip to content

Commit 814ae01

Browse files
committed
Implement FLY002 (convert static string joins to f-strings)
1 parent 59d40f9 commit 814ae01

File tree

10 files changed

+350
-3
lines changed

10 files changed

+350
-3
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import secrets
2+
from random import random, choice
3+
4+
a = "Hello"
5+
ok1 = " ".join([a, " World"]) # OK
6+
ok2 = "".join(["Finally, ", a, " World"]) # OK
7+
ok3 = "x".join(("1", "2", "3")) # OK
8+
ok4 = "y".join([1, 2, 3]) # Technically OK, though would've been an error originally
9+
ok5 = "a".join([random(), random()]) # OK (simple calls)
10+
ok6 = "a".join([secrets.token_urlsafe(), secrets.token_hex()]) # OK (attr calls)
11+
12+
nok1 = "x".join({"4", "5", "yee"}) # Not OK (set)
13+
nok2 = a.join(["1", "2", "3"]) # Not OK (not a static joiner)
14+
nok3 = "a".join(a) # Not OK (not a static joinee)
15+
nok4 = "a".join([a, a, *a]) # Not OK (not a static length)
16+
nok5 = "a".join([choice("flarp")]) # Not OK (not a simple call)
17+
nok6 = "a".join(x for x in "feefoofum") # Not OK (generator)

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

+11-3
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ use crate::rules::{
4343
flake8_django, flake8_errmsg, flake8_gettext, flake8_implicit_str_concat,
4444
flake8_import_conventions, flake8_logging_format, flake8_pie, flake8_print, flake8_pyi,
4545
flake8_pytest_style, flake8_raise, flake8_return, flake8_self, flake8_simplify,
46-
flake8_tidy_imports, flake8_type_checking, flake8_unused_arguments, flake8_use_pathlib, mccabe,
47-
numpy, pandas_vet, pep8_naming, pycodestyle, pydocstyle, pyflakes, pygrep_hooks, pylint,
48-
pyupgrade, ruff, tryceratops,
46+
flake8_tidy_imports, flake8_type_checking, flake8_unused_arguments, flake8_use_pathlib, flynt,
47+
mccabe, numpy, pandas_vet, pep8_naming, pycodestyle, pydocstyle, pyflakes, pygrep_hooks,
48+
pylint, pyupgrade, ruff, tryceratops,
4949
};
5050
use crate::settings::types::PythonVersion;
5151
use crate::settings::{flags, Settings};
@@ -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

+4
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,10 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<Rule> {
732732
(Flake8Django, "012") => Rule::DjangoUnorderedBodyContentInModel,
733733
(Flake8Django, "013") => Rule::DjangoNonLeadingReceiverDecorator,
734734

735+
// flynt
736+
// Reserved: (Flynt, "001") => Rule::StringConcatenationToFString,
737+
(Flynt, "002") => Rule::StaticJoinToFString,
738+
735739
_ => return None,
736740
})
737741
}

crates/ruff/src/registry.rs

+5
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,8 @@ ruff_macros::register_rules!(
666666
rules::flake8_django::rules::DjangoModelWithoutDunderStr,
667667
rules::flake8_django::rules::DjangoUnorderedBodyContentInModel,
668668
rules::flake8_django::rules::DjangoNonLeadingReceiverDecorator,
669+
// flynt
670+
rules::flynt::rules::StaticJoinToFString,
669671
);
670672

671673
pub trait AsRule {
@@ -821,6 +823,9 @@ pub enum Linter {
821823
/// [tryceratops](https://pypi.org/project/tryceratops/1.1.0/)
822824
#[prefix = "TRY"]
823825
Tryceratops,
826+
/// [flynt](https://pypi.org/project/flynt/)
827+
#[prefix = "FLY"]
828+
Flynt,
824829
/// NumPy-specific rules
825830
#[prefix = "NPY"]
826831
Numpy,
+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
use ruff_python_ast::helpers::create_expr;
2+
use rustpython_parser::ast::{Constant, Expr, ExprKind};
3+
4+
fn to_formatted_value_expr(inner: Expr) -> Expr {
5+
create_expr(ExprKind::FormattedValue {
6+
value: Box::new(inner),
7+
conversion: 0,
8+
format_spec: None,
9+
})
10+
}
11+
12+
/// Figure out if `expr` represents a "simple" call
13+
/// (i.e. one that can be safely converted to a formatted value).
14+
fn is_simple_call(expr: &Expr) -> bool {
15+
match &expr.node {
16+
ExprKind::Call {
17+
func,
18+
args,
19+
keywords,
20+
} => args.is_empty() && keywords.is_empty() && is_simple_callee(func),
21+
_ => false,
22+
}
23+
}
24+
25+
/// Figure out if `func` represents a "simple" callee (a bare name, or a chain of simple
26+
/// attribute accesses).
27+
fn is_simple_callee(func: &Expr) -> bool {
28+
match &func.node {
29+
ExprKind::Name { .. } => true,
30+
ExprKind::Attribute { value, .. } => is_simple_callee(value),
31+
_ => false,
32+
}
33+
}
34+
35+
/// Convert an expression to a f-string element (if it looks like a good idea).
36+
pub fn to_fstring_elem(expr: Expr) -> Option<Expr> {
37+
match &expr.node {
38+
// These are directly handled by `unparse_fstring_elem`:
39+
ExprKind::Constant {
40+
value: Constant::Str(_),
41+
..
42+
}
43+
| ExprKind::JoinedStr { .. }
44+
| ExprKind::FormattedValue { .. } => Some(expr),
45+
// These should be pretty safe to wrap in a formatted value.
46+
ExprKind::Constant {
47+
value:
48+
Constant::Int(_) | Constant::Float(_) | Constant::Bool(_) | Constant::Complex { .. },
49+
..
50+
}
51+
| ExprKind::Name { .. }
52+
| ExprKind::Attribute { .. } => Some(to_formatted_value_expr(expr)),
53+
ExprKind::Call { .. } if is_simple_call(&expr) => Some(to_formatted_value_expr(expr)),
54+
_ => None,
55+
}
56+
}
57+
58+
/// Convert a string to a constant string expression.
59+
pub fn to_constant_string(s: &str) -> Expr {
60+
create_expr(ExprKind::Constant {
61+
value: Constant::Str(s.to_owned()),
62+
kind: None,
63+
})
64+
}

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

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//! Rules from [flynt](https://pypi.org/project/flynt/).
2+
mod helpers;
3+
pub(crate) mod rules;
4+
5+
#[cfg(test)]
6+
mod tests {
7+
use crate::registry::Rule;
8+
use crate::test::test_path;
9+
use crate::{assert_messages, settings};
10+
use anyhow::Result;
11+
use std::path::Path;
12+
use test_case::test_case;
13+
14+
#[test_case(Rule::StaticJoinToFString, Path::new("FLY002.py"); "FLY002")]
15+
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
16+
let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy());
17+
let diagnostics = test_path(
18+
Path::new("flynt").join(path).as_path(),
19+
&settings::Settings::for_rule(rule_code),
20+
)?;
21+
assert_messages!(snapshot, diagnostics);
22+
Ok(())
23+
}
24+
}
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
mod static_join_to_fstring;
2+
3+
pub use static_join_to_fstring::{static_join_to_fstring, StaticJoinToFString};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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, unparse_expr};
6+
7+
use crate::checkers::ast::Checker;
8+
use crate::registry::AsRule;
9+
use crate::rules::flynt::helpers;
10+
11+
#[violation]
12+
pub struct StaticJoinToFString {
13+
pub expr: String,
14+
pub fixable: bool,
15+
}
16+
17+
impl Violation for StaticJoinToFString {
18+
const AUTOFIX: AutofixKind = AutofixKind::Sometimes;
19+
20+
#[derive_message_formats]
21+
fn message(&self) -> String {
22+
let StaticJoinToFString { expr, .. } = self;
23+
format!("Consider `{expr}` instead of string join")
24+
}
25+
26+
fn autofix_title_formatter(&self) -> Option<fn(&Self) -> String> {
27+
self.fixable
28+
.then_some(|StaticJoinToFString { expr, .. }| format!("Replace with `{expr}`"))
29+
}
30+
}
31+
32+
fn is_static_length(elts: &[Expr]) -> bool {
33+
elts.iter()
34+
.all(|e| !matches!(e.node, ExprKind::Starred { .. }))
35+
}
36+
37+
fn build_fstring(joiner: &str, joinees: &Vec<Expr>) -> Option<Expr> {
38+
let mut fstring_elems = Vec::with_capacity(joinees.len() * 2);
39+
for (i, expr) in joinees.iter().enumerate() {
40+
let elem = helpers::to_fstring_elem(expr.clone())?;
41+
if i != 0 {
42+
fstring_elems.push(helpers::to_constant_string(joiner));
43+
}
44+
fstring_elems.push(elem);
45+
}
46+
Some(create_expr(ExprKind::JoinedStr {
47+
values: fstring_elems,
48+
}))
49+
}
50+
51+
pub fn static_join_to_fstring(checker: &mut Checker, expr: &Expr, joiner: &str) {
52+
let ExprKind::Call {
53+
func: _,
54+
args,
55+
keywords,
56+
} = &expr.node else {
57+
return;
58+
};
59+
60+
if !keywords.is_empty() || args.len() != 1 {
61+
// If there are kwargs or more than one argument,
62+
// this is some non-standard string join call.
63+
return;
64+
}
65+
66+
// Get the elements to join; skip e.g. generators, sets, etc.
67+
let joinees = match &args[0].node {
68+
ExprKind::List { elts, .. } if is_static_length(elts) => elts,
69+
ExprKind::Tuple { elts, .. } if is_static_length(elts) => elts,
70+
_ => return,
71+
};
72+
73+
// Try to build the fstring (internally checks whether e.g. the elements are
74+
// convertible to f-string parts).
75+
let Some(new_expr) = build_fstring(joiner, joinees) else { return };
76+
77+
let contents = unparse_expr(&new_expr, checker.stylist);
78+
let fixable = true; // I'm not sure there is a case where this is not fixable..?
79+
80+
let mut diagnostic = Diagnostic::new(
81+
StaticJoinToFString {
82+
expr: contents.clone(),
83+
fixable,
84+
},
85+
expr.range(),
86+
);
87+
if checker.patch(diagnostic.kind.rule()) {
88+
if fixable {
89+
diagnostic.set_fix(Edit::range_replacement(contents, expr.range()));
90+
}
91+
}
92+
checker.diagnostics.push(diagnostic);
93+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
---
2+
source: crates/ruff/src/rules/flynt/mod.rs
3+
---
4+
FLY002.py:5:7: FLY002 [*] Consider `f"{a} World"` instead of string join
5+
|
6+
5 | a = "Hello"
7+
6 | ok1 = " ".join([a, " World"]) # OK
8+
| ^^^^^^^^^^^^^^^^^^^^^^^ FLY002
9+
7 | ok2 = "".join(["Finally, ", a, " World"]) # OK
10+
8 | ok3 = "x".join(("1", "2", "3")) # OK
11+
|
12+
= help: Replace with `f"{a} World"`
13+
14+
Suggested fix
15+
2 2 | from random import random, choice
16+
3 3 |
17+
4 4 | a = "Hello"
18+
5 |-ok1 = " ".join([a, " World"]) # OK
19+
5 |+ok1 = f"{a} World" # OK
20+
6 6 | ok2 = "".join(["Finally, ", a, " World"]) # OK
21+
7 7 | ok3 = "x".join(("1", "2", "3")) # OK
22+
8 8 | ok4 = "y".join([1, 2, 3]) # Technically OK, though would've been an error originally
23+
24+
FLY002.py:6:7: FLY002 [*] Consider `f"Finally, {a} World"` instead of string join
25+
|
26+
6 | a = "Hello"
27+
7 | ok1 = " ".join([a, " World"]) # OK
28+
8 | ok2 = "".join(["Finally, ", a, " World"]) # OK
29+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FLY002
30+
9 | ok3 = "x".join(("1", "2", "3")) # OK
31+
10 | ok4 = "y".join([1, 2, 3]) # Technically OK, though would've been an error originally
32+
|
33+
= help: Replace with `f"Finally, {a} World"`
34+
35+
Suggested fix
36+
3 3 |
37+
4 4 | a = "Hello"
38+
5 5 | ok1 = " ".join([a, " World"]) # OK
39+
6 |-ok2 = "".join(["Finally, ", a, " World"]) # OK
40+
6 |+ok2 = f"Finally, {a} World" # OK
41+
7 7 | ok3 = "x".join(("1", "2", "3")) # OK
42+
8 8 | ok4 = "y".join([1, 2, 3]) # Technically OK, though would've been an error originally
43+
9 9 | ok5 = "a".join([random(), random()]) # OK (simple calls)
44+
45+
FLY002.py:7:7: FLY002 [*] Consider `f"1x2x3"` instead of string join
46+
|
47+
7 | ok1 = " ".join([a, " World"]) # OK
48+
8 | ok2 = "".join(["Finally, ", a, " World"]) # OK
49+
9 | ok3 = "x".join(("1", "2", "3")) # OK
50+
| ^^^^^^^^^^^^^^^^^^^^^^^^^ FLY002
51+
10 | ok4 = "y".join([1, 2, 3]) # Technically OK, though would've been an error originally
52+
11 | ok5 = "a".join([random(), random()]) # OK (simple calls)
53+
|
54+
= help: Replace with `f"1x2x3"`
55+
56+
Suggested fix
57+
4 4 | a = "Hello"
58+
5 5 | ok1 = " ".join([a, " World"]) # OK
59+
6 6 | ok2 = "".join(["Finally, ", a, " World"]) # OK
60+
7 |-ok3 = "x".join(("1", "2", "3")) # OK
61+
7 |+ok3 = f"1x2x3" # OK
62+
8 8 | ok4 = "y".join([1, 2, 3]) # Technically OK, though would've been an error originally
63+
9 9 | ok5 = "a".join([random(), random()]) # OK (simple calls)
64+
10 10 | ok6 = "a".join([secrets.token_urlsafe(), secrets.token_hex()]) # OK (attr calls)
65+
66+
FLY002.py:8:7: FLY002 [*] Consider `f"{1}y{2}y{3}"` instead of string join
67+
|
68+
8 | ok2 = "".join(["Finally, ", a, " World"]) # OK
69+
9 | ok3 = "x".join(("1", "2", "3")) # OK
70+
10 | ok4 = "y".join([1, 2, 3]) # Technically OK, though would've been an error originally
71+
| ^^^^^^^^^^^^^^^^^^^ FLY002
72+
11 | ok5 = "a".join([random(), random()]) # OK (simple calls)
73+
12 | ok6 = "a".join([secrets.token_urlsafe(), secrets.token_hex()]) # OK (attr calls)
74+
|
75+
= help: Replace with `f"{1}y{2}y{3}"`
76+
77+
Suggested fix
78+
5 5 | ok1 = " ".join([a, " World"]) # OK
79+
6 6 | ok2 = "".join(["Finally, ", a, " World"]) # OK
80+
7 7 | ok3 = "x".join(("1", "2", "3")) # OK
81+
8 |-ok4 = "y".join([1, 2, 3]) # Technically OK, though would've been an error originally
82+
8 |+ok4 = f"{1}y{2}y{3}" # Technically OK, though would've been an error originally
83+
9 9 | ok5 = "a".join([random(), random()]) # OK (simple calls)
84+
10 10 | ok6 = "a".join([secrets.token_urlsafe(), secrets.token_hex()]) # OK (attr calls)
85+
11 11 |
86+
87+
FLY002.py:9:7: FLY002 [*] Consider `f"{random()}a{random()}"` instead of string join
88+
|
89+
9 | ok3 = "x".join(("1", "2", "3")) # OK
90+
10 | ok4 = "y".join([1, 2, 3]) # Technically OK, though would've been an error originally
91+
11 | ok5 = "a".join([random(), random()]) # OK (simple calls)
92+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FLY002
93+
12 | ok6 = "a".join([secrets.token_urlsafe(), secrets.token_hex()]) # OK (attr calls)
94+
|
95+
= help: Replace with `f"{random()}a{random()}"`
96+
97+
Suggested fix
98+
6 6 | ok2 = "".join(["Finally, ", a, " World"]) # OK
99+
7 7 | ok3 = "x".join(("1", "2", "3")) # OK
100+
8 8 | ok4 = "y".join([1, 2, 3]) # Technically OK, though would've been an error originally
101+
9 |-ok5 = "a".join([random(), random()]) # OK (simple calls)
102+
9 |+ok5 = f"{random()}a{random()}" # OK (simple calls)
103+
10 10 | ok6 = "a".join([secrets.token_urlsafe(), secrets.token_hex()]) # OK (attr calls)
104+
11 11 |
105+
12 12 | nok1 = "x".join({"4", "5", "yee"}) # Not OK (set)
106+
107+
FLY002.py:10:7: FLY002 [*] Consider `f"{secrets.token_urlsafe()}a{secrets.token_hex()}"` instead of string join
108+
|
109+
10 | ok4 = "y".join([1, 2, 3]) # Technically OK, though would've been an error originally
110+
11 | ok5 = "a".join([random(), random()]) # OK (simple calls)
111+
12 | ok6 = "a".join([secrets.token_urlsafe(), secrets.token_hex()]) # OK (attr calls)
112+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FLY002
113+
13 |
114+
14 | nok1 = "x".join({"4", "5", "yee"}) # Not OK (set)
115+
|
116+
= help: Replace with `f"{secrets.token_urlsafe()}a{secrets.token_hex()}"`
117+
118+
Suggested fix
119+
7 7 | ok3 = "x".join(("1", "2", "3")) # OK
120+
8 8 | ok4 = "y".join([1, 2, 3]) # Technically OK, though would've been an error originally
121+
9 9 | ok5 = "a".join([random(), random()]) # OK (simple calls)
122+
10 |-ok6 = "a".join([secrets.token_urlsafe(), secrets.token_hex()]) # OK (attr calls)
123+
10 |+ok6 = f"{secrets.token_urlsafe()}a{secrets.token_hex()}" # OK (attr calls)
124+
11 11 |
125+
12 12 | nok1 = "x".join({"4", "5", "yee"}) # Not OK (set)
126+
13 13 | nok2 = a.join(["1", "2", "3"]) # Not OK (not a static joiner)
127+
128+

0 commit comments

Comments
 (0)