Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
nok5 = "a".join([choice("flarp")]) # Not OK (not a simple call)
nok6 = "a".join(x for x in "feefoofum") # Not OK (generator)
nok7 = "a".join([f"foo{8}", "bar"]) # Not OK (contains an f-string)

nok8 = '\n'.join([r'line1','line2']) # https://github.com/astral-sh/ruff/issues/19887

# Regression test for: https://github.com/astral-sh/ruff/issues/7197
def create_file_public_url(url, filename):
Expand Down
59 changes: 41 additions & 18 deletions crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use ast::FStringFlags;
use itertools::Itertools;

use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{self as ast, Arguments, Expr};
use ruff_python_ast::{self as ast, Arguments, Expr, StringFlags};
use ruff_text_size::{Ranged, TextRange};

use crate::checkers::ast::Checker;
Expand Down Expand Up @@ -72,24 +72,47 @@ fn is_static_length(elts: &[Expr]) -> bool {
fn build_fstring(joiner: &str, joinees: &[Expr], flags: FStringFlags) -> Option<Expr> {
// If all elements are string constants, join them into a single string.
if joinees.iter().all(Expr::is_string_literal_expr) {
let mut flags = None;
let node = ast::StringLiteral {
value: joinees
.iter()
.filter_map(|expr| {
if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = expr {
if flags.is_none() {
// take the flags from the first Expr
flags = Some(value.first_literal_flags());
}
Some(value.to_str())
} else {
None
let mut flags: Option<ast::StringLiteralFlags> = None;
let content = joinees
.iter()
.filter_map(|expr| {
if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = expr {
if flags.is_none() {
// Take the flags from the first Expr
flags = Some(value.first_literal_flags());
}
Some(value.to_str())
} else {
None
}
})
.join(joiner);

let mut flags = flags?;

// If the result is a raw string and contains a newline, use triple quotes.
if flags.prefix().is_raw() && (content.contains('\n') || content.contains('\r')) {
flags = flags.with_triple_quotes(ruff_python_ast::str::TripleQuotes::Yes);

// Ensure we pick a safe triple-quote delimiter that doesn't occur in the content.
let quote = flags.quote_style();
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we could use a combination of StringFlags::quote_str and Quote::opposite to simplify this section a bit.

Should we add a test case (or a few test cases) for this behavior?

I also think we may need to check for both ''' and """ in every string. We could end up with a pathological case like this:

'\n'.join([r"raw string", '<""">', "<'''>"])

which we can't join into a single string with either delimiter. I think if we have a raw string, and the joined string contains our attempted triple quotes, we'll just have to bail out without a fix.

CPython escapes some of the quotes in this case:

>>> '\n'.join([r"raw string", '<""">', "<'''>"])
'raw string\n<""">\n<\'\'\'>'

I think we could also do that with our UnicodeEscape type, but I'd be fine with, or even prefer, avoiding the fix in that case.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you for the suggestion. I have addressed your comment.

match quote {
ruff_python_ast::str::Quote::Single => {
if content.contains("'''") {
flags = flags.with_quote_style(ruff_python_ast::str::Quote::Double);
}
}
ruff_python_ast::str::Quote::Double => {
if content.contains("\"\"\"") {
flags = flags.with_quote_style(ruff_python_ast::str::Quote::Single);
}
})
.join(joiner)
.into_boxed_str(),
flags: flags?,
}
}
}

let node = ast::StringLiteral {
value: content.into_boxed_str(),
flags,
range: TextRange::default(),
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,28 @@ help: Replace with `f"{secrets.token_urlsafe()}a{secrets.token_hex()}"`
13 | nok2 = a.join(["1", "2", "3"]) # Not OK (not a static joiner)
note: This is an unsafe fix and may change runtime behavior

FLY002 [*] Consider f-string instead of string join
--> FLY002.py:19:8
|
17 | nok6 = "a".join(x for x in "feefoofum") # Not OK (generator)
18 | nok7 = "a".join([f"foo{8}", "bar"]) # Not OK (contains an f-string)
19 | nok8 = '\n'.join([r'line1','line2']) # https://github.com/astral-sh/ruff/issues/19887
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
20 |
21 | # Regression test for: https://github.com/astral-sh/ruff/issues/7197
|
help: Replace with f-string
16 | nok5 = "a".join([choice("flarp")]) # Not OK (not a simple call)
17 | nok6 = "a".join(x for x in "feefoofum") # Not OK (generator)
18 | nok7 = "a".join([f"foo{8}", "bar"]) # Not OK (contains an f-string)
- nok8 = '\n'.join([r'line1','line2']) # https://github.com/astral-sh/ruff/issues/19887
19 + nok8 = r'''line1
20 + line2''' # https://github.com/astral-sh/ruff/issues/19887
21 |
22 | # Regression test for: https://github.com/astral-sh/ruff/issues/7197
23 | def create_file_public_url(url, filename):
note: This is an unsafe fix and may change runtime behavior

FLY002 [*] Consider `f"{url}{filename}"` instead of string join
--> FLY002.py:23:11
|
Expand Down
Loading