Join implicit concatenated strings when they fit on a line (#13663)

This commit is contained in:
Micha Reiser 2024-10-24 11:52:22 +02:00 committed by GitHub
parent e402e27a09
commit 73ee72b665
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 3907 additions and 363 deletions

View file

@ -1454,7 +1454,7 @@ impl<Context> std::fmt::Debug for Group<'_, Context> {
/// layout doesn't exceed the line width too, in which case it falls back to the flat layout. /// layout doesn't exceed the line width too, in which case it falls back to the flat layout.
/// ///
/// This IR is identical to the following [`best_fitting`] layout but is implemented as custom IR for /// This IR is identical to the following [`best_fitting`] layout but is implemented as custom IR for
/// best performance. /// better performance.
/// ///
/// ```rust /// ```rust
/// # use ruff_formatter::prelude::*; /// # use ruff_formatter::prelude::*;

View file

@ -139,7 +139,7 @@ where
/// # Ok(()) /// # Ok(())
/// # } /// # }
/// ``` /// ```
pub fn inspect(&mut self, f: &mut Formatter<Context>) -> FormatResult<&[FormatElement]> { pub fn inspect(&self, f: &mut Formatter<Context>) -> FormatResult<&[FormatElement]> {
let result = self.memory.get_or_init(|| f.intern(&self.inner)); let result = self.memory.get_or_init(|| f.intern(&self.inner));
match result.as_ref() { match result.as_ref() {

View file

@ -506,7 +506,7 @@ pub enum StringLikePart<'a> {
FString(&'a ast::FString), FString(&'a ast::FString),
} }
impl StringLikePart<'_> { impl<'a> StringLikePart<'a> {
/// Returns the [`AnyStringFlags`] for the current string-like part. /// Returns the [`AnyStringFlags`] for the current string-like part.
pub fn flags(&self) -> AnyStringFlags { pub fn flags(&self) -> AnyStringFlags {
match self { match self {
@ -525,6 +525,17 @@ impl StringLikePart<'_> {
) )
} }
pub const fn is_string_literal(self) -> bool {
matches!(self, Self::String(_))
}
pub const fn as_string_literal(self) -> Option<&'a ast::StringLiteral> {
match self {
StringLikePart::String(value) => Some(value),
_ => None,
}
}
pub const fn is_fstring(self) -> bool { pub const fn is_fstring(self) -> bool {
matches!(self, Self::FString(_)) matches!(self, Self::FString(_))
} }
@ -571,6 +582,7 @@ impl Ranged for StringLikePart<'_> {
/// An iterator over all the [`StringLikePart`] of a string-like expression. /// An iterator over all the [`StringLikePart`] of a string-like expression.
/// ///
/// This is created by the [`StringLike::parts`] method. /// This is created by the [`StringLike::parts`] method.
#[derive(Clone)]
pub enum StringLikePartIter<'a> { pub enum StringLikePartIter<'a> {
String(std::slice::Iter<'a, ast::StringLiteral>), String(std::slice::Iter<'a, ast::StringLiteral>),
Bytes(std::slice::Iter<'a, ast::BytesLiteral>), Bytes(std::slice::Iter<'a, ast::BytesLiteral>),
@ -607,5 +619,25 @@ impl<'a> Iterator for StringLikePartIter<'a> {
} }
} }
impl DoubleEndedIterator for StringLikePartIter<'_> {
fn next_back(&mut self) -> Option<Self::Item> {
let part = match self {
StringLikePartIter::String(inner) => StringLikePart::String(inner.next_back()?),
StringLikePartIter::Bytes(inner) => StringLikePart::Bytes(inner.next_back()?),
StringLikePartIter::FString(inner) => {
let part = inner.next_back()?;
match part {
ast::FStringPart::Literal(string_literal) => {
StringLikePart::String(string_literal)
}
ast::FStringPart::FString(f_string) => StringLikePart::FString(f_string),
}
}
};
Some(part)
}
}
impl FusedIterator for StringLikePartIter<'_> {} impl FusedIterator for StringLikePartIter<'_> {}
impl ExactSizeIterator for StringLikePartIter<'_> {} impl ExactSizeIterator for StringLikePartIter<'_> {}

View file

@ -0,0 +1,5 @@
[
{
"preview": "enabled"
}
]

View file

@ -0,0 +1,321 @@
"aaaaaaaaa" "bbbbbbbbbbbbbbbbbbbb" # Join
(
"aaaaaaaaaaa" "bbbbbbbbbbbbbbbb"
) # join
(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
) # too long to join
"different '" 'quote "are fine"' # join
# More single quotes
"one single'" "two 'single'" ' two "double"'
# More double quotes
'one double"' 'two "double"' " two 'single'"
# Equal number of single and double quotes
'two "double"' " two 'single'"
f"{'Hy \"User\"'}" 'more'
b"aaaaaaaaa" b"bbbbbbbbbbbbbbbbbbbb" # Join
(
b"aaaaaaaaaaa" b"bbbbbbbbbbbbbbbb"
) # join
(
b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" b"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
) # too long to join
# Skip joining if there is a trailing comment
(
"fffffffffffff"
"bbbbbbbbbbbbb" # comment
"cccccccccccccc"
)
# Skip joining if there is a leading comment
(
"fffffffffffff"
# comment
"bbbbbbbbbbbbb"
"cccccccccccccc"
)
##############################################################################
# F-strings
##############################################################################
# Escape `{` and `}` when marging an f-string with a string
"a {not_a_variable}" f"b {10}" "c"
# Join, and break expressions
f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa{
expression
}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" f"cccccccccccccccccccc {20999}" "more"
# Join, but don't break the expressions
f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa{expression}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" f"cccccccccccccccccccc {20999}" "more"
f"test{
expression
}flat" f"can be {
joined
} together"
aaaaaaaaaaa = f"test{
expression
}flat" f"cean beeeeeeee {
joined
} eeeeeeeeeeeeeeeeeeeeeeeeeeeee" # inline
f"single quoted '{x}'" f'double quoted "{x}"' # Same number of quotes => use preferred quote style
f"single quote ' {x}" f'double quoted "{x}"' # More double quotes => use single quotes
f"single quoted '{x}'" f'double quote " {x}"' # More single quotes => use double quotes
# Different triple quoted strings
f"{'''test'''}" f'{"""other"""}'
# Now with inner quotes
f"{'''test ' '''}" f'{"""other " """}'
f"{some_where_nested('''test ' ''')}" f'{"""other " """ + "more"}'
f"{b'''test ' '''}" f'{b"""other " """}'
f"{f'''test ' '''}" f'{f"""other " """}'
# debug expressions containing quotes
f"{10 + len('bar')=}" f"{10 + len('bar')=}"
f"{10 + len('bar')=}" f'no debug{10}' f"{10 + len('bar')=}"
# We can't savely merge this pre Python 3.12 without altering the debug expression.
f"{10 + len('bar')=}" f'{10 + len("bar")=}'
##############################################################################
# Don't join raw strings
##############################################################################
r"a" "normal"
R"a" "normal"
f"test" fr"test"
f"test" fR"test"
##############################################################################
# Don't join triple quoted strings
##############################################################################
"single" """triple"""
"single" f""""single"""
b"single" b"""triple"""
##############################################################################
# Join strings in with statements
##############################################################################
# Fits
with "aa" "bbb" "cccccccccccccccccccccccccccccccccccccccccccccc":
pass
# Parenthesize single-line
with "aa" "bbb" "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc":
pass
# Multiline
with "aa" "bbb" "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc":
pass
with f"aaaaaaa{expression}bbbb" f"ccc {20999}" "more":
pass
##############################################################################
# For loops
##############################################################################
# Flat
for a in "aaaaaaaaa" "bbbbbbbbb" "ccccccccc" "dddddddddd":
pass
# Parenthesize single-line
for a in "aaaaaaaaa" "bbbbbbbbb" "ccccccccc" "dddddddddd" "eeeeeeeeeeeeeee" "fffffffffffff" "ggggggggggggggg" "hh":
pass
# Multiline
for a in "aaaaaaaaa" "bbbbbbbbb" "ccccccccc" "dddddddddd" "eeeeeeeeeeeeeee" "fffffffffffff" "ggggggggggggggg" "hhhh":
pass
##############################################################################
# Assert statement
##############################################################################
# Fits
assert "aaaaaaaaa" "bbbbbbbbbbbb", "cccccccccccccccc" "dddddddddddddddd"
# Wrap right
assert "aaaaaaaaa" "bbbbbbbbbbbb", "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffff"
# Right multiline
assert "aaaaaaaaa" "bbbbbbbbbbbb", "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff" "ggggggggggggg" "hhhhhhhhhhh"
# Wrap left
assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff", "ggggggggggggg" "hhhhhhhhhhh"
# Left multiline
assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff" "ggggggggggggg", "hhhhhhhhhhh"
# wrap both
assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff", "ggggggggggggg" "hhhhhhhhhhh" "iiiiiiiiiiiiiiiiii" "jjjjjjjjjjjjj" "kkkkkkkkkkkkkkkkk" "llllllllllll"
# both multiline
assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff" "ggggggggggggg", "hhhhhhhhhhh" "iiiiiiiiiiiiiiiiii" "jjjjjjjjjjjjj" "kkkkkkkkkkkkkkkkk" "llllllllllll" "mmmmmmmmmmmmmm"
##############################################################################
# In clause headers (can_omit_optional_parentheses)
##############################################################################
# Use can_omit_optional_parentheses layout to avoid an instability where the formatter
# picks the can_omit_optional_parentheses layout when the strings are joined.
if (
f"implicit"
"concatenated"
"string" + f"implicit"
"concaddddddddddded"
"ring"
* len([aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd])
):
pass
# Keep parenthesizing multiline - implicit concatenated strings
if (
f"implicit"
"""concatenate
d"""
"string" + f"implicit"
"concaddddddddddded"
"ring"
* len([aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd])
):
pass
if (
[
aaaaaa,
bbbbbbbbbbbbbbbb,
cccccccccccccccccc,
ddddddddddddddddddddddddddd,
]
+ "implicitconcat"
"enatedstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing"
):
pass
# In match statements
match x:
case "implicitconcat" "enatedstring" | [
aaaaaa,
bbbbbbbbbbbbbbbb,
cccccccccccccccccc,
ddddddddddddddddddddddddddd,
]:
pass
case [
aaaaaa,
bbbbbbbbbbbbbbbb,
cccccccccccccccccc,
ddddddddddddddddddddddddddd,
] | "implicitconcat" "enatedstring" :
pass
case "implicitconcat" "enatedstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing" | [
aaaaaa,
bbbbbbbbbbbbbbbb,
cccccccccccccccccc,
ddddddddddddddddddddddddddd,
]:
pass
##############################################################################
# In docstring positions
##############################################################################
def short_docstring():
"Implicit" "concatenated" "docstring"
def long_docstring():
"Loooooooooooooooooooooong" "doooooooooooooooooooocstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing" "exceding the line width" "but it should be concatenated anyways because it is single line"
def docstring_with_leading_whitespace():
" This is a " "implicit" "concatenated" "docstring"
def docstring_with_trailing_whitespace():
"This is a " "implicit" "concatenated" "docstring "
def docstring_with_leading_empty_parts():
" " " " "" "This is a " "implicit" "concatenated" "docstring"
def docstring_with_trailing_empty_parts():
"This is a " "implicit" "concatenated" "docstring" "" " " " "
def all_empty():
" " " " " "
def byte_string_in_docstring_position():
b" don't trim the" b"bytes literal "
def f_string_in_docstring_position():
f" don't trim the" "f-string literal "
def single_quoted():
' content\ ' ' '
return
def implicit_with_comment():
(
"a"
# leading
"the comment above"
)
##############################################################################
# Regressions
##############################################################################
LEEEEEEEEEEEEEEEEEEEEEEFT = RRRRRRRRIIIIIIIIIIIIGGGGGHHHT | {
"entityNameeeeeeeeeeeeeeeeee", # comment must be long enough to
"some long implicit concatenated string" "that should join"
}
# Ensure that flipping between Multiline and BestFit layout results in stable formatting
# when using IfBreaksParenthesized layout.
assert False, "Implicit concatenated string" "uses {} layout on {} format".format(
"Multiline", "first"
)
assert False, await "Implicit concatenated string" "uses {} layout on {} format".format(
"Multiline", "first"
)
assert False, "Implicit concatenated stringuses {} layout on {} format"[
aaaaaaaaa, bbbbbb
]
assert False, +"Implicit concatenated string" "uses {} layout on {} format".format(
"Multiline", "first"
)

View file

@ -0,0 +1,293 @@
## Implicit concatenated strings with a trailing comment but a non splittable target.
# Don't join the string because the joined string with the inlined comment exceeds the line length limit.
____aaa = (
"aaaaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv"
) # c
# This is the same string as above and should lead to the same formatting. The only difference is that we start
# with an unparenthesized string.
____aaa = "aaaaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" # c
# Again the same string as above but this time as non-implicit concatenated string.
# It's okay if the formatting differs because it's an explicit choice to use implicit concatenation.
____aaa = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" # c
# Join the string because it's exactly in the line length limit when the comment is inlined.
____aaa = (
"aaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv"
) # c
# This is the same string as above and should lead to the same formatting. The only difference is that we start
# with an unparenthesized string.
____aaa = "aaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" # c
# Again the same string as above but as a non-implicit concatenated string. It should result in the same formatting
# (for consistency).
____aaa = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" # c
# It should collapse the parentheses if the joined string and the comment fit on the same line.
# This is required for stability.
____aaa = (
"aaaaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvv" # c
)
#############################################################
# Assignments where the target or annotations are splittable
#############################################################
# The target splits because of a magic trailing comma
# The string is joined and not parenthesized because it just fits into the line length (including comment).
a[
aaaaaaa,
b,
] = "ccccccccccccccccccccccccccccc" "cccccccccccccccccccccccccccccccccccccccccc" # comment
# Same but starting with a joined string. They should both result in the same formatting.
[
aaaaaaa,
b,
] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment
# The target splits because of the magic trailing comma
# The string is **not** joined because it with the inlined comment exceeds the line length limit.
a[
aaaaaaa,
b,
] = "ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" # comment
# The target should be flat
# The string should be joined because it fits into the line length
a[
aaaaaaa,
b
] = (
"ccccccccccccccccccccccccccccccccccc" "cccccccccccccccccccccccc" # comment
)
# Same but starting with a joined string. They should both result in the same formatting.
a[
aaaaaaa,
b
] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment
# The target should be flat
# The string gets parenthesized because it, with the inlined comment, exceeds the line length limit.
a[
aaaaaaa,
b
] = "ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" # comment
# Split an overlong target, but join the string if it fits
a[
aaaaaaa,
b
].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = (
"ccccccccccccccccccccccccccccccccccccccccc" "cccccccccccccccccccccccccccccc" # comment
)
# Split both if necessary and keep multiline
a[
aaaaaaa,
b
].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = (
"ccccccccccccccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccc" # comment
)
#########################################################
# Leading or trailing own line comments:
# Preserve the parentheses
########################################################
a[
aaaaaaa,
b
] = (
# test
"ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc"
)
a[
aaaaaaa,
b
] = (
"ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc"
# test
)
a[
aaaaaaa,
b
] = (
"ccccccccccccccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc"
# test
)
#############################################################
# Type alias statements
#############################################################
# First break the right, join the string
type A[str, int, number] = "Literal[string, int] | None | " "CustomType" "| OtherCustomTypeExcee" # comment
# Keep multiline if overlong
type A[str, int, number] = "Literal[string, int] | None | " "CustomTypeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" # comment
# Break the left if it is over-long, join the string
type Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[stringgggggggggg, inttttttttttttttttttttttt, number] = "Literal[string, int] | None | " "CustomType" # comment
# Break both if necessary and keep multiline
type Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[stringgggggggggg, inttttttttttttttttttttttt, number] = "Literal[string, int] | None | " "CustomTypeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" # comment
#############################################################
# F-Strings
#############################################################
# Flatten and join the f-string
aaaaaaaaaaa = f"test{
expression}flat" f"cean beeeeeeee {joined} eeeeeeeeeeeeeeeee" # inline
# Parenthesize the value and join it, inline the comment
aaaaaaaaaaa = f"test{
expression}flat" f"cean beeeeeeee {joined} eeeeeeeeeeeeeeeeeeeeeeeeeee" # inline
# Parenthesize the f-string and keep it multiline because it doesn't fit on a single line including the comment
aaaaaaaaaaa = f"test{
expression
}flat" f"cean beeeeeeee {
joined
} eeeeeeeeeeeeeeeeeeeeeeeeeeeee" # inline
# The target splits because of a magic trailing comma
# The string is joined and not parenthesized because it just fits into the line length (including comment).
a[
aaaaaaa,
b,
] = f"ccccc{
expression}ccccccccccc" f"cccccccccccccccccccccccccccccccccccccccccc" # comment
# Same but starting with a joined string. They should both result in the same formatting.
[
aaaaaaa,
b,
] = f"ccccc{
expression}ccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment
# The target splits because of the magic trailing comma
# The string is **not** joined because it with the inlined comment exceeds the line length limit.
a[
aaaaaaa,
b,
] = f"ccccc{
expression}cccccccccccccccccccc" f"cccccccccccccccccccccccccccccccccccccccccc" # comment
# The target should be flat
# The string should be joined because it fits into the line length
a[
aaaaaaa,
b
] = (
f"ccccc{
expression}ccccccccccc" "cccccccccccccccccccccccc" # comment
)
# Same but starting with a joined string. They should both result in the same formatting.
a[
aaaaaaa,
b
] = f"ccccc{
expression}ccccccccccccccccccccccccccccccccccc" # comment
# The target should be flat
# The string gets parenthesized because it, with the inlined comment, exceeds the line length limit.
a[
aaaaaaa,
b
] = f"ccccc{
expression}ccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" # comment
# Split an overlong target, but join the string if it fits
a[
aaaaaaa,
b
].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = (
f"ccccc{
expression}ccccccccccc" "cccccccccccccccccccccccccccccc" # comment
)
# Split both if necessary and keep multiline
a[
aaaaaaa,
b
].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = (
f"ccccc{
expression}cccccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccc" # comment
)
# Don't inline f-strings that contain expressions that are guaranteed to split, e.b. because of a magic trailing comma
aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[a,]
}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[a,]
}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment
)
aaaaa[aaaaaaaaaaa] = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[a,]
}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment
aaaaa[aaaaaaaaaaa] = (f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[a,]
}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment
)
# Don't inline f-strings that contain commented expressions
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{[
a # comment
]}" "moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaa[aaaaaaaaaaa] = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{[
a # comment
]}" "moreeeeeeeeeeeeeeeeeetest" # comment
)
# Don't inline f-strings with multiline debug expressions:
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{
a=}" "moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{a +
b=}" "moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{a
=}" "moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaa[aaaaaaaaaaa] = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{
a=}" "moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaa[aaaaaaaaaaa] = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{a
=}" "moreeeeeeeeeeeeeeeeeetest" # comment
)

View file

@ -0,0 +1,11 @@
[
{
"quote_style": "preserve",
"preview": "enabled"
},
{
"quote_style": "preserve",
"preview": "enabled",
"target_version": "py312"
}
]

View file

@ -0,0 +1,13 @@
a = "different '" 'quote "are fine"' # join
# More single quotes
"one single'" "two 'single'" ' two "double"'
# More double quotes
'one double"' 'two "double"' " two 'single'"
# Equal number of single and double quotes
'two "double"' " two 'single'"
# Already invalid Pre Python 312
f"{'Hy "User"'}" f'{"Hy 'User'"}'

View file

@ -431,6 +431,41 @@ impl<'a> Comments<'a> {
pub(crate) fn debug(&'a self, source_code: SourceCode<'a>) -> DebugComments<'a> { pub(crate) fn debug(&'a self, source_code: SourceCode<'a>) -> DebugComments<'a> {
DebugComments::new(&self.data.comments, source_code) DebugComments::new(&self.data.comments, source_code)
} }
/// Returns true if the node itself or any of its descendants have comments.
pub(crate) fn contains_comments(&self, node: AnyNodeRef) -> bool {
use ruff_python_ast::visitor::source_order::{SourceOrderVisitor, TraversalSignal};
struct Visitor<'a> {
comments: &'a Comments<'a>,
has_comment: bool,
}
impl<'a> SourceOrderVisitor<'a> for Visitor<'a> {
fn enter_node(&mut self, node: AnyNodeRef<'a>) -> TraversalSignal {
if self.has_comment {
TraversalSignal::Skip
} else if self.comments.has(node) {
self.has_comment = true;
TraversalSignal::Skip
} else {
TraversalSignal::Traverse
}
}
}
if self.has(node) {
return true;
}
let mut visitor = Visitor {
comments: self,
has_comment: false,
};
node.visit_preorder(&mut visitor);
visitor.has_comment
}
} }
pub(crate) type LeadingDanglingTrailingComments<'a> = LeadingDanglingTrailing<'a, SourceComment>; pub(crate) type LeadingDanglingTrailingComments<'a> = LeadingDanglingTrailing<'a, SourceComment>;

View file

@ -8,7 +8,6 @@ use ruff_source_file::Locator;
use std::fmt::{Debug, Formatter}; use std::fmt::{Debug, Formatter};
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
#[derive(Clone)]
pub struct PyFormatContext<'a> { pub struct PyFormatContext<'a> {
options: PyFormatOptions, options: PyFormatOptions,
contents: &'a str, contents: &'a str,
@ -52,7 +51,6 @@ impl<'a> PyFormatContext<'a> {
self.contents self.contents
} }
#[allow(unused)]
pub(crate) fn locator(&self) -> Locator<'a> { pub(crate) fn locator(&self) -> Locator<'a> {
Locator::new(self.contents) Locator::new(self.contents)
} }

View file

@ -20,7 +20,7 @@ use crate::expression::parentheses::{
}; };
use crate::expression::OperatorPrecedence; use crate::expression::OperatorPrecedence;
use crate::prelude::*; use crate::prelude::*;
use crate::string::FormatImplicitConcatenatedString; use crate::string::implicit::FormatImplicitConcatenatedString;
#[derive(Copy, Clone, Debug)] #[derive(Copy, Clone, Debug)]
pub(super) enum BinaryLike<'a> { pub(super) enum BinaryLike<'a> {

View file

@ -5,7 +5,8 @@ use crate::expression::parentheses::{
in_parentheses_only_group, NeedsParentheses, OptionalParentheses, in_parentheses_only_group, NeedsParentheses, OptionalParentheses,
}; };
use crate::prelude::*; use crate::prelude::*;
use crate::string::{FormatImplicitConcatenatedString, StringLikeExtensions}; use crate::string::implicit::FormatImplicitConcatenatedStringFlat;
use crate::string::{implicit::FormatImplicitConcatenatedString, StringLikeExtensions};
#[derive(Default)] #[derive(Default)]
pub struct FormatExprBytesLiteral; pub struct FormatExprBytesLiteral;
@ -14,9 +15,19 @@ impl FormatNodeRule<ExprBytesLiteral> for FormatExprBytesLiteral {
fn fmt_fields(&self, item: &ExprBytesLiteral, f: &mut PyFormatter) -> FormatResult<()> { fn fmt_fields(&self, item: &ExprBytesLiteral, f: &mut PyFormatter) -> FormatResult<()> {
let ExprBytesLiteral { value, .. } = item; let ExprBytesLiteral { value, .. } = item;
match value.as_slice() { if let [bytes_literal] = value.as_slice() {
[bytes_literal] => bytes_literal.format().fmt(f), bytes_literal.format().fmt(f)
_ => in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f), } else {
// Always join byte literals that aren't parenthesized and thus, always on a single line.
if !f.context().node_level().is_parenthesized() {
if let Some(format_flat) =
FormatImplicitConcatenatedStringFlat::new(item.into(), f.context())
{
return format_flat.fmt(f);
}
}
in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f)
} }
} }
} }

View file

@ -7,7 +7,8 @@ use crate::expression::parentheses::{
}; };
use crate::other::f_string_part::FormatFStringPart; use crate::other::f_string_part::FormatFStringPart;
use crate::prelude::*; use crate::prelude::*;
use crate::string::{FormatImplicitConcatenatedString, Quoting, StringLikeExtensions}; use crate::string::implicit::FormatImplicitConcatenatedStringFlat;
use crate::string::{implicit::FormatImplicitConcatenatedString, Quoting, StringLikeExtensions};
#[derive(Default)] #[derive(Default)]
pub struct FormatExprFString; pub struct FormatExprFString;
@ -16,13 +17,23 @@ impl FormatNodeRule<ExprFString> for FormatExprFString {
fn fmt_fields(&self, item: &ExprFString, f: &mut PyFormatter) -> FormatResult<()> { fn fmt_fields(&self, item: &ExprFString, f: &mut PyFormatter) -> FormatResult<()> {
let ExprFString { value, .. } = item; let ExprFString { value, .. } = item;
match value.as_slice() { if let [f_string_part] = value.as_slice() {
[f_string_part] => FormatFStringPart::new( FormatFStringPart::new(
f_string_part, f_string_part,
f_string_quoting(item, &f.context().locator()), f_string_quoting(item, &f.context().locator()),
) )
.fmt(f), .fmt(f)
_ => in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f), } else {
// Always join fstrings that aren't parenthesized and thus, are always on a single line.
if !f.context().node_level().is_parenthesized() {
if let Some(format_flat) =
FormatImplicitConcatenatedStringFlat::new(item.into(), f.context())
{
return format_flat.fmt(f);
}
}
in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f)
} }
} }
} }
@ -35,6 +46,7 @@ impl NeedsParentheses for ExprFString {
) -> OptionalParentheses { ) -> OptionalParentheses {
if self.value.is_implicit_concatenated() { if self.value.is_implicit_concatenated() {
OptionalParentheses::Multiline OptionalParentheses::Multiline
}
// TODO(dhruvmanila): Ideally what we want here is a new variant which // TODO(dhruvmanila): Ideally what we want here is a new variant which
// is something like: // is something like:
// - If the expression fits by just adding the parentheses, then add them and // - If the expression fits by just adding the parentheses, then add them and
@ -53,7 +65,7 @@ impl NeedsParentheses for ExprFString {
// ``` // ```
// This isn't decided yet, refer to the relevant discussion: // This isn't decided yet, refer to the relevant discussion:
// https://github.com/astral-sh/ruff/discussions/9785 // https://github.com/astral-sh/ruff/discussions/9785
} else if StringLike::FString(self).is_multiline(context.source()) { else if StringLike::FString(self).is_multiline(context.source()) {
OptionalParentheses::Never OptionalParentheses::Never
} else { } else {
OptionalParentheses::BestFit OptionalParentheses::BestFit

View file

@ -6,7 +6,8 @@ use crate::expression::parentheses::{
}; };
use crate::other::string_literal::StringLiteralKind; use crate::other::string_literal::StringLiteralKind;
use crate::prelude::*; use crate::prelude::*;
use crate::string::{FormatImplicitConcatenatedString, StringLikeExtensions}; use crate::string::implicit::FormatImplicitConcatenatedStringFlat;
use crate::string::{implicit::FormatImplicitConcatenatedString, StringLikeExtensions};
#[derive(Default)] #[derive(Default)]
pub struct FormatExprStringLiteral { pub struct FormatExprStringLiteral {
@ -26,16 +27,20 @@ impl FormatNodeRule<ExprStringLiteral> for FormatExprStringLiteral {
fn fmt_fields(&self, item: &ExprStringLiteral, f: &mut PyFormatter) -> FormatResult<()> { fn fmt_fields(&self, item: &ExprStringLiteral, f: &mut PyFormatter) -> FormatResult<()> {
let ExprStringLiteral { value, .. } = item; let ExprStringLiteral { value, .. } = item;
match value.as_slice() { if let [string_literal] = value.as_slice() {
[string_literal] => string_literal.format().with_options(self.kind).fmt(f), string_literal.format().with_options(self.kind).fmt(f)
_ => { } else {
// This is just a sanity check because [`DocstringStmt::try_from_statement`] // Always join strings that aren't parenthesized and thus, always on a single line.
// ensures that the docstring is a *single* string literal. if !f.context().node_level().is_parenthesized() {
assert!(!self.kind.is_docstring()); if let Some(mut format_flat) =
FormatImplicitConcatenatedStringFlat::new(item.into(), f.context())
in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)) {
format_flat.set_docstring(self.kind.is_docstring());
return format_flat.fmt(f);
} }
.fmt(f), }
in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f)
} }
} }
} }

View file

@ -21,6 +21,7 @@ use crate::expression::parentheses::{
use crate::prelude::*; use crate::prelude::*;
use crate::preview::{ use crate::preview::{
is_empty_parameters_no_unnecessary_parentheses_around_return_value_enabled, is_empty_parameters_no_unnecessary_parentheses_around_return_value_enabled,
is_f_string_implicit_concatenated_string_literal_quotes_enabled,
is_hug_parens_with_braces_and_square_brackets_enabled, is_hug_parens_with_braces_and_square_brackets_enabled,
}; };
@ -405,38 +406,39 @@ impl Format<PyFormatContext<'_>> for MaybeParenthesizeExpression<'_> {
needs_parentheses => needs_parentheses, needs_parentheses => needs_parentheses,
}; };
let unparenthesized = expression.format().with_options(Parentheses::Never);
match needs_parentheses { match needs_parentheses {
OptionalParentheses::Multiline => match parenthesize { OptionalParentheses::Multiline => match parenthesize {
Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested if !is_empty_parameters_no_unnecessary_parentheses_around_return_value_enabled(f.context()) => { Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested if !is_empty_parameters_no_unnecessary_parentheses_around_return_value_enabled(f.context()) => {
parenthesize_if_expands(&expression.format().with_options(Parentheses::Never)) parenthesize_if_expands(&unparenthesized).fmt(f)
.fmt(f)
}
Parenthesize::IfRequired => {
expression.format().with_options(Parentheses::Never).fmt(f)
} }
Parenthesize::IfRequired => unparenthesized.fmt(f),
Parenthesize::Optional | Parenthesize::IfBreaks | Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested => { Parenthesize::Optional | Parenthesize::IfBreaks | Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested => {
if can_omit_optional_parentheses(expression, f.context()) { if can_omit_optional_parentheses(expression, f.context()) {
optional_parentheses(&expression.format().with_options(Parentheses::Never)) optional_parentheses(&unparenthesized).fmt(f)
.fmt(f)
} else { } else {
parenthesize_if_expands( parenthesize_if_expands(&unparenthesized).fmt(f)
&expression.format().with_options(Parentheses::Never),
)
.fmt(f)
} }
} }
}, },
OptionalParentheses::BestFit => match parenthesize { OptionalParentheses::BestFit => match parenthesize {
Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested if !is_empty_parameters_no_unnecessary_parentheses_around_return_value_enabled(f.context()) =>
parenthesize_if_expands(&unparenthesized).fmt(f),
Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested => { Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested => {
parenthesize_if_expands(&expression.format().with_options(Parentheses::Never)) // Can-omit layout is relevant for `"abcd".call`. We don't want to add unnecessary
.fmt(f) // parentheses in this case.
if can_omit_optional_parentheses(expression, f.context()) {
optional_parentheses(&unparenthesized).fmt(f)
} else {
parenthesize_if_expands(&unparenthesized).fmt(f)
}
} }
Parenthesize::Optional | Parenthesize::IfRequired => { Parenthesize::Optional | Parenthesize::IfRequired => unparenthesized.fmt(f),
expression.format().with_options(Parentheses::Never).fmt(f)
}
Parenthesize::IfBreaks => { Parenthesize::IfBreaks => {
if node_comments.has_trailing() { if node_comments.has_trailing() {
@ -446,7 +448,7 @@ impl Format<PyFormatContext<'_>> for MaybeParenthesizeExpression<'_> {
let group_id = f.group_id("optional_parentheses"); let group_id = f.group_id("optional_parentheses");
let f = &mut WithNodeLevel::new(NodeLevel::Expression(Some(group_id)), f); let f = &mut WithNodeLevel::new(NodeLevel::Expression(Some(group_id)), f);
best_fit_parenthesize(&expression.format().with_options(Parentheses::Never)) best_fit_parenthesize(&unparenthesized)
.with_group_id(Some(group_id)) .with_group_id(Some(group_id))
.fmt(f) .fmt(f)
} }
@ -454,13 +456,13 @@ impl Format<PyFormatContext<'_>> for MaybeParenthesizeExpression<'_> {
}, },
OptionalParentheses::Never => match parenthesize { OptionalParentheses::Never => match parenthesize {
Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested if !is_empty_parameters_no_unnecessary_parentheses_around_return_value_enabled(f.context()) => { Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested if !is_empty_parameters_no_unnecessary_parentheses_around_return_value_enabled(f.context()) => {
parenthesize_if_expands(&expression.format().with_options(Parentheses::Never)) parenthesize_if_expands(&unparenthesized)
.with_indent(!is_expression_huggable(expression, f.context())) .with_indent(!is_expression_huggable(expression, f.context()))
.fmt(f) .fmt(f)
} }
Parenthesize::Optional | Parenthesize::IfBreaks | Parenthesize::IfRequired | Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested => { Parenthesize::Optional | Parenthesize::IfBreaks | Parenthesize::IfRequired | Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested => {
expression.format().with_options(Parentheses::Never).fmt(f) unparenthesized.fmt(f)
} }
}, },
@ -768,15 +770,26 @@ impl<'input> CanOmitOptionalParenthesesVisitor<'input> {
Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) Expr::StringLiteral(ast::ExprStringLiteral { value, .. })
if value.is_implicit_concatenated() => if value.is_implicit_concatenated() =>
{ {
if !is_f_string_implicit_concatenated_string_literal_quotes_enabled(self.context) {
self.update_max_precedence(OperatorPrecedence::String); self.update_max_precedence(OperatorPrecedence::String);
} }
return;
}
Expr::BytesLiteral(ast::ExprBytesLiteral { value, .. }) Expr::BytesLiteral(ast::ExprBytesLiteral { value, .. })
if value.is_implicit_concatenated() => if value.is_implicit_concatenated() =>
{ {
if !is_f_string_implicit_concatenated_string_literal_quotes_enabled(self.context) {
self.update_max_precedence(OperatorPrecedence::String); self.update_max_precedence(OperatorPrecedence::String);
} }
return;
}
Expr::FString(ast::ExprFString { value, .. }) if value.is_implicit_concatenated() => { Expr::FString(ast::ExprFString { value, .. }) if value.is_implicit_concatenated() => {
if !is_f_string_implicit_concatenated_string_literal_quotes_enabled(self.context) {
self.update_max_precedence(OperatorPrecedence::String); self.update_max_precedence(OperatorPrecedence::String);
}
return; return;
} }

View file

@ -76,14 +76,9 @@ impl Format<PyFormatContext<'_>> for FormatFString<'_> {
let quotes = StringQuotes::from(string_kind); let quotes = StringQuotes::from(string_kind);
write!(f, [string_kind.prefix(), quotes])?; write!(f, [string_kind.prefix(), quotes])?;
f.join() for element in &self.value.elements {
.entries( FormatFStringElement::new(element, context).fmt(f)?;
self.value }
.elements
.iter()
.map(|element| FormatFStringElement::new(element, context)),
)
.finish()?;
// Ending quote // Ending quote
quotes.fmt(f) quotes.fmt(f)
@ -98,7 +93,7 @@ pub(crate) struct FStringContext {
} }
impl FStringContext { impl FStringContext {
const fn new(flags: AnyStringFlags, layout: FStringLayout) -> Self { pub(crate) const fn new(flags: AnyStringFlags, layout: FStringLayout) -> Self {
Self { Self {
enclosing_flags: flags, enclosing_flags: flags,
layout, layout,
@ -125,7 +120,7 @@ pub(crate) enum FStringLayout {
} }
impl FStringLayout { impl FStringLayout {
fn from_f_string(f_string: &FString, locator: &Locator) -> Self { pub(crate) fn from_f_string(f_string: &FString, locator: &Locator) -> Self {
// Heuristic: Allow breaking the f-string expressions across multiple lines // Heuristic: Allow breaking the f-string expressions across multiple lines
// only if there already is at least one multiline expression. This puts the // only if there already is at least one multiline expression. This puts the
// control in the hands of the user to decide if they want to break the // control in the hands of the user to decide if they want to break the

View file

@ -61,7 +61,8 @@ impl<'a> FormatFStringLiteralElement<'a> {
impl Format<PyFormatContext<'_>> for FormatFStringLiteralElement<'_> { impl Format<PyFormatContext<'_>> for FormatFStringLiteralElement<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
let literal_content = f.context().locator().slice(self.element.range()); let literal_content = f.context().locator().slice(self.element.range());
let normalized = normalize_string(literal_content, 0, self.fstring_flags, true); let normalized =
normalize_string(literal_content, 0, self.fstring_flags, false, false, true);
match &normalized { match &normalized {
Cow::Borrowed(_) => source_text_slice(self.element.range()).fmt(f), Cow::Borrowed(_) => source_text_slice(self.element.range()).fmt(f),
Cow::Owned(normalized) => text(normalized).fmt(f), Cow::Owned(normalized) => text(normalized).fmt(f),
@ -235,11 +236,9 @@ impl Format<PyFormatContext<'_>> for FormatFStringExpressionElement<'_> {
if let Some(format_spec) = format_spec.as_deref() { if let Some(format_spec) = format_spec.as_deref() {
token(":").fmt(f)?; token(":").fmt(f)?;
f.join() for element in &format_spec.elements {
.entries(format_spec.elements.iter().map(|element| { FormatFStringElement::new(element, self.context.f_string()).fmt(f)?;
FormatFStringElement::new(element, self.context.f_string()) }
}))
.finish()?;
// These trailing comments can only occur if the format specifier is // These trailing comments can only occur if the format specifier is
// present. For example, // present. For example,

View file

@ -14,6 +14,7 @@ use crate::expression::parentheses::{
optional_parentheses, parenthesized, NeedsParentheses, OptionalParentheses, Parentheses, optional_parentheses, parenthesized, NeedsParentheses, OptionalParentheses, Parentheses,
}; };
use crate::prelude::*; use crate::prelude::*;
use crate::preview::is_join_implicit_concatenated_string_enabled;
pub(crate) mod pattern_arguments; pub(crate) mod pattern_arguments;
pub(crate) mod pattern_keyword; pub(crate) mod pattern_keyword;
@ -226,7 +227,7 @@ pub(crate) fn can_pattern_omit_optional_parentheses(
pattern: &Pattern, pattern: &Pattern,
context: &PyFormatContext, context: &PyFormatContext,
) -> bool { ) -> bool {
let mut visitor = CanOmitOptionalParenthesesVisitor::default(); let mut visitor = CanOmitOptionalParenthesesVisitor::new(context);
visitor.visit_pattern(pattern, context); visitor.visit_pattern(pattern, context);
if !visitor.any_parenthesized_expressions { if !visitor.any_parenthesized_expressions {
@ -271,16 +272,32 @@ pub(crate) fn can_pattern_omit_optional_parentheses(
} }
} }
#[derive(Debug, Default)] #[derive(Debug)]
struct CanOmitOptionalParenthesesVisitor<'input> { struct CanOmitOptionalParenthesesVisitor<'input> {
max_precedence: OperatorPrecedence, max_precedence: OperatorPrecedence,
max_precedence_count: usize, max_precedence_count: usize,
any_parenthesized_expressions: bool, any_parenthesized_expressions: bool,
join_implicit_concatenated_strings: bool,
last: Option<&'input Pattern>, last: Option<&'input Pattern>,
first: First<'input>, first: First<'input>,
} }
impl<'a> CanOmitOptionalParenthesesVisitor<'a> { impl<'a> CanOmitOptionalParenthesesVisitor<'a> {
fn new(context: &PyFormatContext) -> Self {
Self {
max_precedence: OperatorPrecedence::default(),
max_precedence_count: 0,
any_parenthesized_expressions: false,
// TODO: Derive default for `CanOmitOptionalParenthesesVisitor` when removing the `join_implicit_concatenated_strings`
// preview style.
join_implicit_concatenated_strings: is_join_implicit_concatenated_string_enabled(
context,
),
last: None,
first: First::default(),
}
}
fn visit_pattern(&mut self, pattern: &'a Pattern, context: &PyFormatContext) { fn visit_pattern(&mut self, pattern: &'a Pattern, context: &PyFormatContext) {
match pattern { match pattern {
Pattern::MatchSequence(_) | Pattern::MatchMapping(_) => { Pattern::MatchSequence(_) | Pattern::MatchMapping(_) => {
@ -289,19 +306,25 @@ impl<'a> CanOmitOptionalParenthesesVisitor<'a> {
Pattern::MatchValue(value) => match &*value.value { Pattern::MatchValue(value) => match &*value.value {
Expr::StringLiteral(string) => { Expr::StringLiteral(string) => {
if !self.join_implicit_concatenated_strings {
self.update_max_precedence(OperatorPrecedence::String, string.value.len()); self.update_max_precedence(OperatorPrecedence::String, string.value.len());
} }
}
Expr::BytesLiteral(bytes) => { Expr::BytesLiteral(bytes) => {
if !self.join_implicit_concatenated_strings {
self.update_max_precedence(OperatorPrecedence::String, bytes.value.len()); self.update_max_precedence(OperatorPrecedence::String, bytes.value.len());
} }
}
// F-strings are allowed according to python's grammar but fail with a syntax error at runtime. // F-strings are allowed according to python's grammar but fail with a syntax error at runtime.
// That's why we need to support them for formatting. // That's why we need to support them for formatting.
Expr::FString(string) => { Expr::FString(string) => {
if !self.join_implicit_concatenated_strings {
self.update_max_precedence( self.update_max_precedence(
OperatorPrecedence::String, OperatorPrecedence::String,
string.value.as_slice().len(), string.value.as_slice().len(),
); );
} }
}
Expr::NumberLiteral(_) | Expr::Attribute(_) | Expr::UnaryOp(_) => { Expr::NumberLiteral(_) | Expr::Attribute(_) | Expr::UnaryOp(_) => {
// require no state update other than visit_pattern does. // require no state update other than visit_pattern does.

View file

@ -62,3 +62,11 @@ pub(crate) fn is_docstring_code_block_in_docstring_indent_enabled(
) -> bool { ) -> bool {
context.is_preview() context.is_preview()
} }
/// Returns `true` if implicitly concatenated strings should be joined if they all fit on a single line.
/// See [#9457](https://github.com/astral-sh/ruff/issues/9457)
/// WARNING: This preview style depends on `is_empty_parameters_no_unnecessary_parentheses_around_return_value_enabled`
/// because it relies on the new semantic of `IfBreaksParenthesized`.
pub(crate) fn is_join_implicit_concatenated_string_enabled(context: &PyFormatContext) -> bool {
context.is_preview()
}

View file

@ -211,9 +211,9 @@ impl<'ast> SourceOrderVisitor<'ast> for FindEnclosingNode<'_, 'ast> {
// Don't pick potential docstrings as the closest enclosing node because `suite.rs` than fails to identify them as // Don't pick potential docstrings as the closest enclosing node because `suite.rs` than fails to identify them as
// docstrings and docstring formatting won't kick in. // docstrings and docstring formatting won't kick in.
// Format the enclosing node instead and slice the formatted docstring from the result. // Format the enclosing node instead and slice the formatted docstring from the result.
let is_maybe_docstring = node.as_stmt_expr().is_some_and(|stmt| { let is_maybe_docstring = node
DocstringStmt::is_docstring_statement(stmt, self.context.options().source_type()) .as_stmt_expr()
}); .is_some_and(|stmt| DocstringStmt::is_docstring_statement(stmt, self.context));
if is_maybe_docstring { if is_maybe_docstring {
return TraversalSignal::Skip; return TraversalSignal::Skip;

View file

@ -6,6 +6,7 @@ use crate::comments::SourceComment;
use crate::expression::maybe_parenthesize_expression; use crate::expression::maybe_parenthesize_expression;
use crate::expression::parentheses::Parenthesize; use crate::expression::parentheses::Parenthesize;
use crate::preview::is_join_implicit_concatenated_string_enabled;
use crate::{has_skip_comment, prelude::*}; use crate::{has_skip_comment, prelude::*};
#[derive(Default)] #[derive(Default)]
@ -29,12 +30,18 @@ impl FormatNodeRule<StmtAssert> for FormatStmtAssert {
)?; )?;
if let Some(msg) = msg { if let Some(msg) = msg {
let parenthesize = if is_join_implicit_concatenated_string_enabled(f.context()) {
Parenthesize::IfBreaksParenthesized
} else {
Parenthesize::IfBreaks
};
write!( write!(
f, f,
[ [
token(","), token(","),
space(), space(),
maybe_parenthesize_expression(msg, item, Parenthesize::IfBreaks), maybe_parenthesize_expression(msg, item, parenthesize),
] ]
)?; )?;
} }

View file

@ -1,6 +1,6 @@
use ruff_formatter::{format_args, write, FormatError}; use ruff_formatter::{format_args, write, FormatError, RemoveSoftLinesBuffer};
use ruff_python_ast::{ use ruff_python_ast::{
AnyNodeRef, Expr, ExprAttribute, ExprCall, Operator, StmtAssign, TypeParams, AnyNodeRef, Expr, ExprAttribute, ExprCall, Operator, StmtAssign, StringLike, TypeParams,
}; };
use crate::builders::parenthesize_if_expands; use crate::builders::parenthesize_if_expands;
@ -16,7 +16,11 @@ use crate::expression::{
can_omit_optional_parentheses, has_own_parentheses, has_parentheses, can_omit_optional_parentheses, has_own_parentheses, has_parentheses,
maybe_parenthesize_expression, maybe_parenthesize_expression,
}; };
use crate::preview::is_join_implicit_concatenated_string_enabled;
use crate::statement::trailing_semicolon; use crate::statement::trailing_semicolon;
use crate::string::implicit::{
FormatImplicitConcatenatedStringExpanded, FormatImplicitConcatenatedStringFlat,
};
use crate::{has_skip_comment, prelude::*}; use crate::{has_skip_comment, prelude::*};
#[derive(Default)] #[derive(Default)]
@ -281,8 +285,11 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
match self { match self {
FormatStatementsLastExpression::LeftToRight { value, statement } => { FormatStatementsLastExpression::LeftToRight { value, statement } => {
let can_inline_comment = should_inline_comments(value, *statement, f.context()); let can_inline_comment = should_inline_comments(value, *statement, f.context());
let format_implicit_flat = StringLike::try_from(*value).ok().and_then(|string| {
FormatImplicitConcatenatedStringFlat::new(string, f.context())
});
if !can_inline_comment { if !can_inline_comment && format_implicit_flat.is_none() {
return maybe_parenthesize_expression( return maybe_parenthesize_expression(
value, value,
*statement, *statement,
@ -301,9 +308,129 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
) { ) {
let group_id = f.group_id("optional_parentheses"); let group_id = f.group_id("optional_parentheses");
let f = &mut WithNodeLevel::new(NodeLevel::Expression(Some(group_id)), f); // Special case for implicit concatenated strings in assignment value positions.
// The special handling is necessary to prevent an instability where an assignment has
// a trailing own line comment and the implicit concatenated string fits on the line,
// but only if the comment doesn't get inlined.
//
// ```python
// ____aaa = (
// "aaaaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv"
// ) # c
// ```
//
// Without the special handling, this would get formatted to:
// ```python
// ____aaa = (
// "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv"
// ) # c
// ```
//
// However, this now gets reformatted again because Ruff now takes the `BestFit` layout for the string
// because the value is no longer an implicit concatenated string.
// ```python
// ____aaa = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" # c
// ```
//
// The special handling here ensures that the implicit concatenated string only gets
// joined **if** it fits with the trailing comment inlined. Otherwise, keep the multiline
// formatting.
if let Some(flat) = format_implicit_flat {
inline_comments.mark_formatted();
let string = flat.string();
best_fit_parenthesize(&format_with(|f| { let flat = format_with(|f| {
if string.is_fstring() {
let mut buffer = RemoveSoftLinesBuffer::new(&mut *f);
write!(buffer, [flat])
} else {
flat.fmt(f)
}
})
.memoized();
// F-String containing an expression with a magic trailing comma, a comment, or a
// multiline debug expression should never be joined. Use the default layout.
// ```python
// aaaa = f"abcd{[
// 1,
// 2,
// ]}" "more"
// ```
if string.is_fstring() && flat.inspect(f)?.will_break() {
inline_comments.mark_unformatted();
return write!(
f,
[maybe_parenthesize_expression(
value,
*statement,
Parenthesize::IfBreaks,
)]
);
}
let expanded = format_with(|f| {
let f =
&mut WithNodeLevel::new(NodeLevel::Expression(Some(group_id)), f);
write!(f, [FormatImplicitConcatenatedStringExpanded::new(string)])
});
// Join the implicit concatenated string if it fits on a single line
// ```python
// a = "testmorelong" # comment
// ```
let single_line = format_with(|f| write!(f, [flat, inline_comments]));
// Parenthesize the string but join the implicit concatenated string and inline the comment.
// ```python
// a = (
// "testmorelong" # comment
// )
// ```
let joined_parenthesized = format_with(|f| {
group(&format_args![
token("("),
soft_block_indent(&format_args![flat, inline_comments]),
token(")"),
])
.with_group_id(Some(group_id))
.should_expand(true)
.fmt(f)
});
// Keep the implicit concatenated string multiline and don't inline the comment.
// ```python
// a = (
// "test"
// "more"
// "long"
// ) # comment
// ```
let implicit_expanded = format_with(|f| {
group(&format_args![
token("("),
block_indent(&expanded),
token(")"),
inline_comments,
])
.with_group_id(Some(group_id))
.should_expand(true)
.fmt(f)
});
// We can't use `optional_parentheses` here because the `inline_comments` contains
// a `expand_parent` which results in an instability because the next format
// collapses the parentheses.
// We can't use `parenthesize_if_expands` because it defaults to
// the *flat* layout when the expanded layout doesn't fit.
best_fitting![single_line, joined_parenthesized, implicit_expanded]
.with_mode(BestFittingMode::AllLines)
.fmt(f)?;
} else {
best_fit_parenthesize(&format_once(|f| {
inline_comments.mark_formatted(); inline_comments.mark_formatted();
value.format().with_options(Parentheses::Never).fmt(f)?; value.format().with_options(Parentheses::Never).fmt(f)?;
@ -324,6 +451,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
.with_group_id(Some(group_id)) .with_group_id(Some(group_id))
.fmt(f)?; .fmt(f)?;
} }
}
Ok(()) Ok(())
} else { } else {
@ -339,10 +467,14 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
statement, statement,
} => { } => {
let should_inline_comments = should_inline_comments(value, *statement, f.context()); let should_inline_comments = should_inline_comments(value, *statement, f.context());
let format_implicit_flat = StringLike::try_from(*value).ok().and_then(|string| {
FormatImplicitConcatenatedStringFlat::new(string, f.context())
});
// Use the normal `maybe_parenthesize_layout` for splittable `value`s. // Use the normal `maybe_parenthesize_layout` for splittable `value`s.
if !should_inline_comments if !should_inline_comments
&& !should_non_inlineable_use_best_fit(value, *statement, f.context()) && !should_non_inlineable_use_best_fit(value, *statement, f.context())
&& format_implicit_flat.is_none()
{ {
return write!( return write!(
f, f,
@ -364,7 +496,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
let expression_comments = comments.leading_dangling_trailing(*value); let expression_comments = comments.leading_dangling_trailing(*value);
// Don't inline comments for attribute and call expressions for black compatibility // Don't inline comments for attribute and call expressions for black compatibility
let inline_comments = if should_inline_comments { let inline_comments = if should_inline_comments || format_implicit_flat.is_some() {
OptionalParenthesesInlinedComments::new( OptionalParenthesesInlinedComments::new(
&expression_comments, &expression_comments,
*statement, *statement,
@ -396,13 +528,14 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
// Prevent inline comments to be formatted as part of the expression. // Prevent inline comments to be formatted as part of the expression.
inline_comments.mark_formatted(); inline_comments.mark_formatted();
let mut last_target = before_operator.memoized(); let last_target = before_operator.memoized();
let last_target_breaks = last_target.inspect(f)?.will_break();
// Don't parenthesize the `value` if it is known that the target will break. // Don't parenthesize the `value` if it is known that the target will break.
// This is mainly a performance optimisation that avoids unnecessary memoization // This is mainly a performance optimisation that avoids unnecessary memoization
// and using the costly `BestFitting` layout if it is already known that only the last variant // and using the costly `BestFitting` layout if it is already known that only the last variant
// can ever fit because the left breaks. // can ever fit because the left breaks.
if last_target.inspect(f)?.will_break() { if format_implicit_flat.is_none() && last_target_breaks {
return write!( return write!(
f, f,
[ [
@ -416,13 +549,29 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
); );
} }
let format_value = value.format().with_options(Parentheses::Never).memoized(); let format_value = format_with(|f| {
if let Some(format_implicit_flat) = format_implicit_flat.as_ref() {
if format_implicit_flat.string().is_fstring() {
// Remove any soft line breaks emitted by the f-string formatting.
// This is important when formatting f-strings as part of an assignment right side
// because `best_fit_parenthesize` will otherwise still try to break inner
// groups if wrapped in a `group(..).should_expand(true)`
let mut buffer = RemoveSoftLinesBuffer::new(&mut *f);
write!(buffer, [format_implicit_flat])
} else {
format_implicit_flat.fmt(f)
}
} else {
value.format().with_options(Parentheses::Never).fmt(f)
}
})
.memoized();
// Tries to fit the `left` and the `value` on a single line: // Tries to fit the `left` and the `value` on a single line:
// ```python // ```python
// a = b = c // a = b = c
// ``` // ```
let format_flat = format_with(|f| { let single_line = format_with(|f| {
write!( write!(
f, f,
[ [
@ -443,19 +592,21 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
// c // c
// ) // )
// ``` // ```
let format_parenthesize_value = format_with(|f| { let flat_target_parenthesize_value = format_with(|f| {
write!( write!(f, [last_target, space(), operator, space(), token("("),])?;
f,
[ if is_join_implicit_concatenated_string_enabled(f.context()) {
last_target, group(&soft_block_indent(&format_args![
space(), format_value,
operator, inline_comments
space(), ]))
token("("), .should_expand(true)
block_indent(&format_args![format_value, inline_comments]), .fmt(f)?;
token(")") } else {
] block_indent(&format_args![format_value, inline_comments]).fmt(f)?;
) }
token(")").fmt(f)
}); });
// Fall back to parenthesizing (or splitting) the last target part if we can't make the value // Fall back to parenthesizing (or splitting) the last target part if we can't make the value
@ -466,17 +617,16 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
// "bbbbb" // "bbbbb"
// ] = c // ] = c
// ``` // ```
let format_split_left = format_with(|f| { let split_target_flat_value = format_with(|f| {
if is_join_implicit_concatenated_string_enabled(f.context()) {
group(&last_target).should_expand(true).fmt(f)?;
} else {
last_target.fmt(f)?;
}
write!( write!(
f, f,
[ [space(), operator, space(), format_value, inline_comments]
last_target,
space(),
operator,
space(),
format_value,
inline_comments
]
) )
}); });
@ -486,7 +636,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
// For attribute chains that contain any parenthesized value: Try expanding the parenthesized value first. // For attribute chains that contain any parenthesized value: Try expanding the parenthesized value first.
if value.is_call_expr() || value.is_subscript_expr() || value.is_attribute_expr() { if value.is_call_expr() || value.is_subscript_expr() || value.is_attribute_expr() {
best_fitting![ best_fitting![
format_flat, single_line,
// Avoid parenthesizing the call expression if the `(` fit on the line // Avoid parenthesizing the call expression if the `(` fit on the line
format_args![ format_args![
last_target, last_target,
@ -495,12 +645,165 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
space(), space(),
group(&format_value).should_expand(true), group(&format_value).should_expand(true),
], ],
format_parenthesize_value, flat_target_parenthesize_value,
format_split_left split_target_flat_value
] ]
.fmt(f) .fmt(f)
} else if let Some(format_implicit_flat) = &format_implicit_flat {
// F-String containing an expression with a magic trailing comma, a comment, or a
// multiline debug expression should never be joined. Use the default layout.
//
// ```python
// aaaa = f"abcd{[
// 1,
// 2,
// ]}" "more"
// ```
if format_implicit_flat.string().is_fstring()
&& format_value.inspect(f)?.will_break()
{
inline_comments.mark_unformatted();
return write!(
f,
[
before_operator,
space(),
operator,
space(),
maybe_parenthesize_expression(
value,
*statement,
Parenthesize::IfBreaks
)
]
);
}
let group_id = f.group_id("optional_parentheses");
let format_expanded = format_with(|f| {
let f = &mut WithNodeLevel::new(NodeLevel::Expression(Some(group_id)), f);
FormatImplicitConcatenatedStringExpanded::new(
StringLike::try_from(*value).unwrap(),
)
.fmt(f)
})
.memoized();
// Keep the target flat, parenthesize the value, and keep it multiline.
//
// ```python
// Literal[ "a", "b"] = (
// "looooooooooooooooooooooooooooooong"
// "string"
// ) # comment
// ```
let flat_target_value_parenthesized_multiline = format_with(|f| {
write!(
f,
[
last_target,
space(),
operator,
space(),
token("("),
group(&soft_block_indent(&format_expanded))
.with_group_id(Some(group_id))
.should_expand(true),
token(")"),
inline_comments
]
)
});
// Expand the parent and parenthesize the joined string with the inlined comment.
//
// ```python
// Literal[
// "a",
// "b",
// ] = (
// "not that long string" # comment
// )
// ```
let split_target_value_parenthesized_flat = format_with(|f| {
write!(
f,
[
group(&last_target).should_expand(true),
space(),
operator,
space(),
token("("),
group(&soft_block_indent(&format_args![
format_value,
inline_comments
]))
.should_expand(true),
token(")")
]
)
});
// The most expanded variant: Expand both the target and the string.
//
// ```python
// Literal[
// "a",
// "b",
// ] = (
// "looooooooooooooooooooooooooooooong"
// "string"
// ) # comment
// ```
let split_target_value_parenthesized_multiline = format_with(|f| {
write!(
f,
[
group(&last_target).should_expand(true),
space(),
operator,
space(),
token("("),
group(&soft_block_indent(&format_expanded))
.with_group_id(Some(group_id))
.should_expand(true),
token(")"),
inline_comments
]
)
});
// This is only a perf optimisation. No point in trying all the "flat-target"
// variants if we know that the last target must break.
if last_target_breaks {
best_fitting![
split_target_flat_value,
split_target_value_parenthesized_flat,
split_target_value_parenthesized_multiline,
]
.with_mode(BestFittingMode::AllLines)
.fmt(f)
} else { } else {
best_fitting![format_flat, format_parenthesize_value, format_split_left].fmt(f) best_fitting![
single_line,
flat_target_parenthesize_value,
flat_target_value_parenthesized_multiline,
split_target_flat_value,
split_target_value_parenthesized_flat,
split_target_value_parenthesized_multiline,
]
.with_mode(BestFittingMode::AllLines)
.fmt(f)
}
} else {
best_fitting![
single_line,
flat_target_parenthesize_value,
split_target_flat_value
]
.fmt(f)
} }
} }
} }
@ -556,6 +859,12 @@ impl<'a> OptionalParenthesesInlinedComments<'a> {
comment.mark_formatted(); comment.mark_formatted();
} }
} }
fn mark_unformatted(&self) {
for comment in self.expression {
comment.mark_unformatted();
}
}
} }
impl Format<PyFormatContext<'_>> for OptionalParenthesesInlinedComments<'_> { impl Format<PyFormatContext<'_>> for OptionalParenthesesInlinedComments<'_> {

View file

@ -138,7 +138,7 @@ impl FormatRule<Suite, PyFormatContext<'_>> for FormatSuite {
SuiteKind::Function | SuiteKind::Class | SuiteKind::TopLevel => { SuiteKind::Function | SuiteKind::Class | SuiteKind::TopLevel => {
if let Some(docstring) = if let Some(docstring) =
DocstringStmt::try_from_statement(first, self.kind, source_type) DocstringStmt::try_from_statement(first, self.kind, f.context())
{ {
SuiteChildStatement::Docstring(docstring) SuiteChildStatement::Docstring(docstring)
} else { } else {
@ -179,7 +179,7 @@ impl FormatRule<Suite, PyFormatContext<'_>> for FormatSuite {
// Insert a newline after a module level docstring, but treat // Insert a newline after a module level docstring, but treat
// it as a docstring otherwise. See: https://github.com/psf/black/pull/3932. // it as a docstring otherwise. See: https://github.com/psf/black/pull/3932.
self.kind == SuiteKind::TopLevel self.kind == SuiteKind::TopLevel
&& DocstringStmt::try_from_statement(first.statement(), self.kind, source_type) && DocstringStmt::try_from_statement(first.statement(), self.kind, f.context())
.is_some() .is_some()
}; };
@ -785,37 +785,23 @@ impl<'a> DocstringStmt<'a> {
fn try_from_statement( fn try_from_statement(
stmt: &'a Stmt, stmt: &'a Stmt,
suite_kind: SuiteKind, suite_kind: SuiteKind,
source_type: PySourceType, context: &PyFormatContext,
) -> Option<DocstringStmt<'a>> { ) -> Option<DocstringStmt<'a>> {
// Notebooks don't have a concept of modules, therefore, don't recognise the first string as the module docstring. // Notebooks don't have a concept of modules, therefore, don't recognise the first string as the module docstring.
if source_type.is_ipynb() && suite_kind == SuiteKind::TopLevel { if context.options().source_type().is_ipynb() && suite_kind == SuiteKind::TopLevel {
return None; return None;
} }
let Stmt::Expr(ast::StmtExpr { value, .. }) = stmt else { Self::is_docstring_statement(stmt.as_expr_stmt()?, context).then_some(DocstringStmt {
return None;
};
match value.as_ref() {
Expr::StringLiteral(ast::ExprStringLiteral { value, .. })
if !value.is_implicit_concatenated() =>
{
Some(DocstringStmt {
docstring: stmt, docstring: stmt,
suite_kind, suite_kind,
}) })
} }
_ => None,
}
}
pub(crate) fn is_docstring_statement(stmt: &StmtExpr, source_type: PySourceType) -> bool {
if source_type.is_ipynb() {
return false;
}
pub(crate) fn is_docstring_statement(stmt: &StmtExpr, context: &PyFormatContext) -> bool {
if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = stmt.value.as_ref() { if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = stmt.value.as_ref() {
!value.is_implicit_concatenated() !value.is_implicit_concatenated()
|| !value.iter().any(|literal| context.comments().has(literal))
} else { } else {
false false
} }

View file

@ -9,7 +9,7 @@ use std::{borrow::Cow, collections::VecDeque};
use regex::Regex; use regex::Regex;
use ruff_formatter::printer::SourceMapGeneration; use ruff_formatter::printer::SourceMapGeneration;
use ruff_python_ast::{str::Quote, StringFlags}; use ruff_python_ast::{str::Quote, AnyStringFlags, StringFlags};
use ruff_python_trivia::CommentRanges; use ruff_python_trivia::CommentRanges;
use { use {
ruff_formatter::{write, FormatOptions, IndentStyle, LineWidth, Printed}, ruff_formatter::{write, FormatOptions, IndentStyle, LineWidth, Printed},
@ -19,7 +19,10 @@ use {
}; };
use super::NormalizedString; use super::NormalizedString;
use crate::preview::is_docstring_code_block_in_docstring_indent_enabled; use crate::preview::{
is_docstring_code_block_in_docstring_indent_enabled,
is_join_implicit_concatenated_string_enabled,
};
use crate::string::StringQuotes; use crate::string::StringQuotes;
use crate::{prelude::*, DocstringCodeLineWidth, FormatModuleError}; use crate::{prelude::*, DocstringCodeLineWidth, FormatModuleError};
@ -167,7 +170,7 @@ pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form
if docstring[first.len()..].trim().is_empty() { if docstring[first.len()..].trim().is_empty() {
// For `"""\n"""` or other whitespace between the quotes, black keeps a single whitespace, // For `"""\n"""` or other whitespace between the quotes, black keeps a single whitespace,
// but `""""""` doesn't get one inserted. // but `""""""` doesn't get one inserted.
if needs_chaperone_space(normalized, trim_end) if needs_chaperone_space(normalized.flags(), trim_end, f.context())
|| (trim_end.is_empty() && !docstring.is_empty()) || (trim_end.is_empty() && !docstring.is_empty())
{ {
space().fmt(f)?; space().fmt(f)?;
@ -207,7 +210,7 @@ pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form
let trim_end = docstring let trim_end = docstring
.as_ref() .as_ref()
.trim_end_matches(|c: char| c.is_whitespace() && c != '\n'); .trim_end_matches(|c: char| c.is_whitespace() && c != '\n');
if needs_chaperone_space(normalized, trim_end) { if needs_chaperone_space(normalized.flags(), trim_end, f.context()) {
space().fmt(f)?; space().fmt(f)?;
} }
@ -1604,9 +1607,18 @@ fn docstring_format_source(
/// If the last line of the docstring is `content" """` or `content\ """`, we need a chaperone space /// If the last line of the docstring is `content" """` or `content\ """`, we need a chaperone space
/// that avoids `content""""` and `content\"""`. This does only applies to un-escaped backslashes, /// that avoids `content""""` and `content\"""`. This does only applies to un-escaped backslashes,
/// so `content\\ """` doesn't need a space while `content\\\ """` does. /// so `content\\ """` doesn't need a space while `content\\\ """` does.
fn needs_chaperone_space(normalized: &NormalizedString, trim_end: &str) -> bool { pub(super) fn needs_chaperone_space(
trim_end.ends_with(normalized.flags().quote_style().as_char()) flags: AnyStringFlags,
|| trim_end.chars().rev().take_while(|c| *c == '\\').count() % 2 == 1 trim_end: &str,
context: &PyFormatContext,
) -> bool {
if trim_end.chars().rev().take_while(|c| *c == '\\').count() % 2 == 1 {
true
} else if is_join_implicit_concatenated_string_enabled(context) {
flags.is_triple_quoted() && trim_end.ends_with(flags.quote_style().as_char())
} else {
trim_end.ends_with(flags.quote_style().as_char())
}
} }
#[derive(Copy, Clone, Debug)] #[derive(Copy, Clone, Debug)]

View file

@ -0,0 +1,406 @@
use std::borrow::Cow;
use ruff_formatter::{format_args, write, FormatContext};
use ruff_python_ast::str::Quote;
use ruff_python_ast::str_prefix::{
AnyStringPrefix, ByteStringPrefix, FStringPrefix, StringLiteralPrefix,
};
use ruff_python_ast::{AnyStringFlags, FStringElement, StringFlags, StringLike, StringLikePart};
use ruff_text_size::{Ranged, TextRange};
use crate::comments::{leading_comments, trailing_comments};
use crate::expression::parentheses::in_parentheses_only_soft_line_break_or_space;
use crate::other::f_string::{FStringContext, FStringLayout, FormatFString};
use crate::other::f_string_element::FormatFStringExpressionElement;
use crate::other::string_literal::StringLiteralKind;
use crate::prelude::*;
use crate::preview::{
is_f_string_formatting_enabled, is_join_implicit_concatenated_string_enabled,
};
use crate::string::docstring::needs_chaperone_space;
use crate::string::normalize::{
is_fstring_with_quoted_debug_expression,
is_fstring_with_triple_quoted_literal_expression_containing_quotes, QuoteMetadata,
};
use crate::string::{normalize_string, StringLikeExtensions, StringNormalizer, StringQuotes};
/// Formats any implicitly concatenated string. This could be any valid combination
/// of string, bytes or f-string literals.
pub(crate) struct FormatImplicitConcatenatedString<'a> {
string: StringLike<'a>,
}
impl<'a> FormatImplicitConcatenatedString<'a> {
pub(crate) fn new(string: impl Into<StringLike<'a>>) -> Self {
Self {
string: string.into(),
}
}
}
impl Format<PyFormatContext<'_>> for FormatImplicitConcatenatedString<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
let expanded = FormatImplicitConcatenatedStringExpanded::new(self.string);
// If the string can be joined, try joining the implicit concatenated string into a single string
// if it fits on the line. Otherwise, parenthesize the string parts and format each part on its
// own line.
if let Some(flat) = FormatImplicitConcatenatedStringFlat::new(self.string, f.context()) {
write!(
f,
[if_group_fits_on_line(&flat), if_group_breaks(&expanded)]
)
} else {
expanded.fmt(f)
}
}
}
/// Formats an implicit concatenated string where parts are separated by a space or line break.
pub(crate) struct FormatImplicitConcatenatedStringExpanded<'a> {
string: StringLike<'a>,
}
impl<'a> FormatImplicitConcatenatedStringExpanded<'a> {
pub(crate) fn new(string: StringLike<'a>) -> Self {
assert!(string.is_implicit_concatenated());
Self { string }
}
}
impl Format<PyFormatContext<'_>> for FormatImplicitConcatenatedStringExpanded<'_> {
fn fmt(&self, f: &mut Formatter<PyFormatContext<'_>>) -> FormatResult<()> {
let comments = f.context().comments().clone();
let quoting = self.string.quoting(&f.context().locator());
let join_implicit_concatenated_string_enabled =
is_join_implicit_concatenated_string_enabled(f.context());
let mut joiner = f.join_with(in_parentheses_only_soft_line_break_or_space());
for part in self.string.parts() {
let format_part = format_with(|f: &mut PyFormatter| match part {
StringLikePart::String(part) => {
let kind = if self.string.is_fstring() {
#[allow(deprecated)]
StringLiteralKind::InImplicitlyConcatenatedFString(quoting)
} else {
StringLiteralKind::String
};
part.format().with_options(kind).fmt(f)
}
StringLikePart::Bytes(bytes_literal) => bytes_literal.format().fmt(f),
StringLikePart::FString(part) => FormatFString::new(part, quoting).fmt(f),
});
let part_comments = comments.leading_dangling_trailing(&part);
joiner.entry(&format_args![
(!join_implicit_concatenated_string_enabled).then_some(line_suffix_boundary()),
leading_comments(part_comments.leading),
format_part,
trailing_comments(part_comments.trailing)
]);
}
joiner.finish()
}
}
/// Formats an implicit concatenated string where parts are joined into a single string if possible.
pub(crate) struct FormatImplicitConcatenatedStringFlat<'a> {
string: StringLike<'a>,
flags: AnyStringFlags,
docstring: bool,
}
impl<'a> FormatImplicitConcatenatedStringFlat<'a> {
/// Creates a new formatter. Returns `None` if the string can't be merged into a single string.
pub(crate) fn new(string: StringLike<'a>, context: &PyFormatContext) -> Option<Self> {
fn merge_flags(string: StringLike, context: &PyFormatContext) -> Option<AnyStringFlags> {
if !is_join_implicit_concatenated_string_enabled(context) {
return None;
}
// Multiline strings can never fit on a single line.
if !string.is_fstring() && string.is_multiline(context.source()) {
return None;
}
let first_part = string.parts().next()?;
// The string is either a regular string, f-string, or bytes string.
let normalizer = StringNormalizer::from_context(context);
// Some if a part requires preserving its quotes.
let mut preserve_quotes_requirement: Option<Quote> = None;
// Early exit if it's known that this string can't be joined
for part in string.parts() {
// Similar to Black, don't collapse triple quoted and raw strings.
// We could technically join strings that are raw-strings and use the same quotes but lets not do this for now.
// Joining triple quoted strings is more complicated because an
// implicit concatenated string could become a docstring (if it's the first string in a block).
// That means the joined string formatting would have to call into
// the docstring formatting or otherwise guarantee that the output
// won't change on a second run.
if part.flags().is_triple_quoted() || part.flags().is_raw_string() {
return None;
}
// For now, preserve comments documenting a specific part over possibly
// collapsing onto a single line. Collapsing could result in pragma comments
// now covering more code.
if context.comments().leading_trailing(&part).next().is_some() {
return None;
}
if let StringLikePart::FString(fstring) = part {
if fstring.elements.iter().any(|element| match element {
// Same as for other literals. Multiline literals can't fit on a single line.
FStringElement::Literal(literal) => context
.locator()
.slice(literal.range())
.contains(['\n', '\r']),
FStringElement::Expression(expression) => {
if is_f_string_formatting_enabled(context) {
// Expressions containing comments can't be joined.
context.comments().contains_comments(expression.into())
} else {
// Multiline f-string expressions can't be joined if the f-string formatting is disabled because
// the string gets inserted in verbatim preserving the newlines.
context.locator().slice(expression).contains(['\n', '\r'])
}
}
}) {
return None;
}
// Avoid invalid syntax for pre Python 312:
// * When joining parts that have debug expressions with quotes: `f"{10 + len('bar')=}" f'{10 + len("bar")=}'
// * When joining parts that contain triple quoted strings with quotes: `f"{'''test ' '''}" f'{"""other " """}'`
if !context.options().target_version().supports_pep_701() {
if is_fstring_with_quoted_debug_expression(fstring, context)
|| is_fstring_with_triple_quoted_literal_expression_containing_quotes(
fstring, context,
)
{
if preserve_quotes_requirement
.is_some_and(|quote| quote != part.flags().quote_style())
{
return None;
}
preserve_quotes_requirement = Some(part.flags().quote_style());
}
}
}
}
// The string is either a regular string, f-string, or bytes string.
let mut merged_quotes: Option<QuoteMetadata> = None;
// Only preserve the string type but disregard the `u` and `r` prefixes.
// * It's not necessary to preserve the `r` prefix because Ruff doesn't support joining raw strings (we shouldn't get here).
// * It's not necessary to preserve the `u` prefix because Ruff discards the `u` prefix (it's meaningless in Python 3+)
let prefix = match string {
StringLike::String(_) => AnyStringPrefix::Regular(StringLiteralPrefix::Empty),
StringLike::Bytes(_) => AnyStringPrefix::Bytes(ByteStringPrefix::Regular),
StringLike::FString(_) => AnyStringPrefix::Format(FStringPrefix::Regular),
};
// Only determining the preferred quote for the first string is sufficient
// because we don't support joining triple quoted strings with non triple quoted strings.
let quote = if let Ok(preferred_quote) =
Quote::try_from(normalizer.preferred_quote_style(first_part))
{
for part in string.parts() {
let part_quote_metadata =
QuoteMetadata::from_part(part, context, preferred_quote);
if let Some(merged) = merged_quotes.as_mut() {
*merged = part_quote_metadata.merge(merged)?;
} else {
merged_quotes = Some(part_quote_metadata);
}
}
merged_quotes?.choose(preferred_quote)
} else {
// Use the quotes of the first part if the quotes should be preserved.
first_part.flags().quote_style()
};
Some(AnyStringFlags::new(prefix, quote, false))
}
if !string.is_implicit_concatenated() {
return None;
}
Some(Self {
flags: merge_flags(string, context)?,
string,
docstring: false,
})
}
pub(crate) fn set_docstring(&mut self, is_docstring: bool) {
self.docstring = is_docstring;
}
pub(crate) fn string(&self) -> StringLike<'a> {
self.string
}
}
impl Format<PyFormatContext<'_>> for FormatImplicitConcatenatedStringFlat<'_> {
fn fmt(&self, f: &mut Formatter<PyFormatContext<'_>>) -> FormatResult<()> {
// Merges all string parts into a single string.
let quotes = StringQuotes::from(self.flags);
write!(f, [self.flags.prefix(), quotes])?;
let mut parts = self.string.parts().peekable();
// Trim implicit concatenated strings in docstring positions.
// Skip over any trailing parts that are all whitespace.
// Leading parts are handled as part of the formatting loop below.
if self.docstring {
for part in self.string.parts().rev() {
assert!(part.is_string_literal());
if f.context()
.locator()
.slice(part.content_range())
.trim()
.is_empty()
{
// Don't format the part.
parts.next_back();
} else {
break;
}
}
}
let mut first_non_empty = self.docstring;
while let Some(part) = parts.next() {
match part {
StringLikePart::String(_) | StringLikePart::Bytes(_) => {
FormatLiteralContent {
range: part.content_range(),
flags: self.flags,
is_fstring: false,
trim_start: first_non_empty && self.docstring,
trim_end: self.docstring && parts.peek().is_none(),
}
.fmt(f)?;
if first_non_empty {
first_non_empty = f
.context()
.locator()
.slice(part.content_range())
.trim_start()
.is_empty();
}
}
StringLikePart::FString(f_string) => {
if is_f_string_formatting_enabled(f.context()) {
for element in &f_string.elements {
match element {
FStringElement::Literal(literal) => {
FormatLiteralContent {
range: literal.range(),
flags: self.flags,
is_fstring: true,
trim_end: false,
trim_start: false,
}
.fmt(f)?;
}
// Formatting the expression here and in the expanded version is safe **only**
// because we assert that the f-string never contains any comments.
FStringElement::Expression(expression) => {
let context = FStringContext::new(
self.flags,
FStringLayout::from_f_string(
f_string,
&f.context().locator(),
),
);
FormatFStringExpressionElement::new(expression, context)
.fmt(f)?;
}
}
}
} else {
FormatLiteralContent {
range: part.content_range(),
flags: self.flags,
is_fstring: true,
trim_end: false,
trim_start: false,
}
.fmt(f)?;
}
}
}
}
quotes.fmt(f)
}
}
struct FormatLiteralContent {
range: TextRange,
flags: AnyStringFlags,
is_fstring: bool,
trim_start: bool,
trim_end: bool,
}
impl Format<PyFormatContext<'_>> for FormatLiteralContent {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
let content = f.context().locator().slice(self.range);
let mut normalized = normalize_string(
content,
0,
self.flags,
self.flags.is_f_string() && !self.is_fstring,
true,
false,
);
// Trim the start and end of the string if it's the first or last part of a docstring.
// This is rare, so don't bother with optimizing to use `Cow`.
if self.trim_start {
let trimmed = normalized.trim_start();
if trimmed.len() < normalized.len() {
normalized = trimmed.to_string().into();
}
}
if self.trim_end {
let trimmed = normalized.trim_end();
if trimmed.len() < normalized.len() {
normalized = trimmed.to_string().into();
}
}
if !normalized.is_empty() {
match &normalized {
Cow::Borrowed(_) => source_text_slice(self.range).fmt(f)?,
Cow::Owned(normalized) => text(normalized).fmt(f)?,
}
if self.trim_end && needs_chaperone_space(self.flags, &normalized, f.context()) {
space().fmt(f)?;
}
}
Ok(())
}
}

View file

@ -1,25 +1,21 @@
use memchr::memchr2; use memchr::memchr2;
pub(crate) use normalize::{normalize_string, NormalizedString, StringNormalizer}; pub(crate) use normalize::{normalize_string, NormalizedString, StringNormalizer};
use ruff_formatter::format_args;
use ruff_python_ast::str::Quote; use ruff_python_ast::str::Quote;
use ruff_python_ast::{ use ruff_python_ast::{
self as ast, self as ast,
str_prefix::{AnyStringPrefix, StringLiteralPrefix}, str_prefix::{AnyStringPrefix, StringLiteralPrefix},
AnyStringFlags, StringFlags, StringLike, StringLikePart, AnyStringFlags, StringFlags,
}; };
use ruff_source_file::Locator; use ruff_source_file::Locator;
use ruff_text_size::Ranged; use ruff_text_size::Ranged;
use crate::comments::{leading_comments, trailing_comments};
use crate::expression::expr_f_string::f_string_quoting; use crate::expression::expr_f_string::f_string_quoting;
use crate::expression::parentheses::in_parentheses_only_soft_line_break_or_space;
use crate::other::f_string::FormatFString;
use crate::other::string_literal::StringLiteralKind;
use crate::prelude::*; use crate::prelude::*;
use crate::QuoteStyle; use crate::QuoteStyle;
pub(crate) mod docstring; pub(crate) mod docstring;
pub(crate) mod implicit;
mod normalize; mod normalize;
#[derive(Copy, Clone, Debug, Default)] #[derive(Copy, Clone, Debug, Default)]
@ -29,57 +25,6 @@ pub(crate) enum Quoting {
Preserve, Preserve,
} }
/// Formats any implicitly concatenated string. This could be any valid combination
/// of string, bytes or f-string literals.
pub(crate) struct FormatImplicitConcatenatedString<'a> {
string: StringLike<'a>,
}
impl<'a> FormatImplicitConcatenatedString<'a> {
pub(crate) fn new(string: impl Into<StringLike<'a>>) -> Self {
Self {
string: string.into(),
}
}
}
impl Format<PyFormatContext<'_>> for FormatImplicitConcatenatedString<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
let comments = f.context().comments().clone();
let quoting = self.string.quoting(&f.context().locator());
let mut joiner = f.join_with(in_parentheses_only_soft_line_break_or_space());
for part in self.string.parts() {
let part_comments = comments.leading_dangling_trailing(&part);
let format_part = format_with(|f: &mut PyFormatter| match part {
StringLikePart::String(part) => {
let kind = if self.string.is_fstring() {
#[allow(deprecated)]
StringLiteralKind::InImplicitlyConcatenatedFString(quoting)
} else {
StringLiteralKind::String
};
part.format().with_options(kind).fmt(f)
}
StringLikePart::Bytes(bytes_literal) => bytes_literal.format().fmt(f),
StringLikePart::FString(part) => FormatFString::new(part, quoting).fmt(f),
});
joiner.entry(&format_args![
line_suffix_boundary(),
leading_comments(part_comments.leading),
format_part,
trailing_comments(part_comments.trailing)
]);
}
joiner.finish()
}
}
impl Format<PyFormatContext<'_>> for AnyStringPrefix { impl Format<PyFormatContext<'_>> for AnyStringPrefix {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
// Remove the unicode prefix `u` if any because it is meaningless in Python 3+. // Remove the unicode prefix `u` if any because it is meaningless in Python 3+.
@ -159,12 +104,10 @@ impl StringLikeExtensions for ast::StringLike<'_> {
fn is_multiline(&self, source: &str) -> bool { fn is_multiline(&self, source: &str) -> bool {
match self { match self {
Self::String(_) | Self::Bytes(_) => { Self::String(_) | Self::Bytes(_) => self.parts().any(|part| {
self.parts() part.flags().is_triple_quoted()
.next()
.is_some_and(|part| part.flags().is_triple_quoted())
&& memchr2(b'\n', b'\r', source[self.range()].as_bytes()).is_some() && memchr2(b'\n', b'\r', source[self.range()].as_bytes()).is_some()
} }),
Self::FString(fstring) => { Self::FString(fstring) => {
memchr2(b'\n', b'\r', source[fstring.range].as_bytes()).is_some() memchr2(b'\n', b'\r', source[fstring.range].as_bytes()).is_some()
} }

View file

@ -44,6 +44,9 @@ impl<'a, 'src> StringNormalizer<'a, 'src> {
/// The formatter should use the preferred quote style unless /// The formatter should use the preferred quote style unless
/// it can't because the string contains the preferred quotes OR /// it can't because the string contains the preferred quotes OR
/// it leads to more escaping. /// it leads to more escaping.
///
/// Note: If you add more cases here where we return `QuoteStyle::Preserve`,
/// make sure to also add them to [`FormatImplicitConcatenatedStringFlat::new`].
pub(super) fn preferred_quote_style(&self, string: StringLikePart) -> QuoteStyle { pub(super) fn preferred_quote_style(&self, string: StringLikePart) -> QuoteStyle {
match self.quoting { match self.quoting {
Quoting::Preserve => QuoteStyle::Preserve, Quoting::Preserve => QuoteStyle::Preserve,
@ -205,6 +208,8 @@ impl<'a, 'src> StringNormalizer<'a, 'src> {
quote_selection.flags, quote_selection.flags,
// TODO: Remove the `b'{'` in `choose_quotes` when promoting the // TODO: Remove the `b'{'` in `choose_quotes` when promoting the
// `format_fstring` preview style // `format_fstring` preview style
false,
false,
is_f_string_formatting_enabled(self.context), is_f_string_formatting_enabled(self.context),
) )
} else { } else {
@ -598,6 +603,8 @@ pub(crate) fn normalize_string(
input: &str, input: &str,
start_offset: usize, start_offset: usize,
new_flags: AnyStringFlags, new_flags: AnyStringFlags,
escape_braces: bool,
flip_nested_fstring_quotes: bool,
format_f_string: bool, format_f_string: bool,
) -> Cow<str> { ) -> Cow<str> {
// The normalized string if `input` is not yet normalized. // The normalized string if `input` is not yet normalized.
@ -620,6 +627,13 @@ pub(crate) fn normalize_string(
while let Some((index, c)) = chars.next() { while let Some((index, c)) = chars.next() {
if matches!(c, '{' | '}') && is_fstring { if matches!(c, '{' | '}') && is_fstring {
if escape_braces {
// Escape `{` and `}` when converting a regular string literal to an f-string literal.
output.push_str(&input[last_index..=index]);
output.push(c);
last_index = index + c.len_utf8();
continue;
} else if is_fstring {
if chars.peek().copied().is_some_and(|(_, next)| next == c) { if chars.peek().copied().is_some_and(|(_, next)| next == c) {
// Skip over the second character of the double braces // Skip over the second character of the double braces
chars.next(); chars.next();
@ -631,6 +645,7 @@ pub(crate) fn normalize_string(
} }
continue; continue;
} }
}
if c == '\r' { if c == '\r' {
output.push_str(&input[last_index..index]); output.push_str(&input[last_index..index]);
@ -697,6 +712,14 @@ pub(crate) fn normalize_string(
output.push('\\'); output.push('\\');
output.push(c); output.push(c);
last_index = index + preferred_quote.len_utf8(); last_index = index + preferred_quote.len_utf8();
} else if c == preferred_quote
&& flip_nested_fstring_quotes
&& formatted_value_nesting > 0
{
// Flip the quotes
output.push_str(&input[last_index..index]);
output.push(opposite_quote);
last_index = index + preferred_quote.len_utf8();
} }
} }
} }
@ -981,6 +1004,7 @@ pub(super) fn is_fstring_with_triple_quoted_literal_expression_containing_quotes
mod tests { mod tests {
use std::borrow::Cow; use std::borrow::Cow;
use ruff_python_ast::str_prefix::FStringPrefix;
use ruff_python_ast::{ use ruff_python_ast::{
str::Quote, str::Quote,
str_prefix::{AnyStringPrefix, ByteStringPrefix}, str_prefix::{AnyStringPrefix, ByteStringPrefix},
@ -1013,9 +1037,35 @@ mod tests {
Quote::Double, Quote::Double,
false, false,
), ),
false,
false,
true, true,
); );
assert_eq!(r"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a", &normalized); assert_eq!(r"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a", &normalized);
} }
#[test]
fn normalize_nested_fstring() {
let input =
r#"With single quote: ' {my_dict['foo']} With double quote: " {my_dict["bar"]}"#;
let normalized = normalize_string(
input,
0,
AnyStringFlags::new(
AnyStringPrefix::Format(FStringPrefix::Regular),
Quote::Double,
false,
),
false,
true,
false,
);
assert_eq!(
"With single quote: ' {my_dict['foo']} With double quote: \\\" {my_dict['bar']}",
&normalized
);
}
} }

View file

@ -6,7 +6,11 @@ use {
use ruff_python_ast::visitor::transformer; use ruff_python_ast::visitor::transformer;
use ruff_python_ast::visitor::transformer::Transformer; use ruff_python_ast::visitor::transformer::Transformer;
use ruff_python_ast::{self as ast, Expr, Stmt}; use ruff_python_ast::{
self as ast, BytesLiteralFlags, Expr, FStringElement, FStringFlags, FStringLiteralElement,
FStringPart, Stmt, StringFlags, StringLiteralFlags,
};
use ruff_text_size::{Ranged, TextRange};
/// A struct to normalize AST nodes for the purpose of comparing formatted representations for /// A struct to normalize AST nodes for the purpose of comparing formatted representations for
/// semantic equivalence. /// semantic equivalence.
@ -59,6 +63,135 @@ impl Transformer for Normalizer {
transformer::walk_stmt(self, stmt); transformer::walk_stmt(self, stmt);
} }
fn visit_expr(&self, expr: &mut Expr) {
// Ruff supports joining implicitly concatenated strings. The code below implements this
// at an AST level by joining the string literals in the AST if they can be joined (it doesn't mean that
// they'll be joined in the formatted output but they could).
// Comparable expression handles some of this by comparing the concatenated string
// but not joining here doesn't play nicely with other string normalizations done in the
// Normalizer.
match expr {
Expr::StringLiteral(string) => {
if string.value.is_implicit_concatenated() {
let can_join = string.value.iter().all(|literal| {
!literal.flags.is_triple_quoted() && !literal.flags.prefix().is_raw()
});
if can_join {
string.value = ast::StringLiteralValue::single(ast::StringLiteral {
value: string.value.to_str().to_string().into_boxed_str(),
range: string.range,
flags: StringLiteralFlags::default(),
});
}
}
}
Expr::BytesLiteral(bytes) => {
if bytes.value.is_implicit_concatenated() {
let can_join = bytes.value.iter().all(|literal| {
!literal.flags.is_triple_quoted() && !literal.flags.prefix().is_raw()
});
if can_join {
bytes.value = ast::BytesLiteralValue::single(ast::BytesLiteral {
value: bytes.value.bytes().collect(),
range: bytes.range,
flags: BytesLiteralFlags::default(),
});
}
}
}
Expr::FString(fstring) => {
if fstring.value.is_implicit_concatenated() {
let can_join = fstring.value.iter().all(|part| match part {
FStringPart::Literal(literal) => {
!literal.flags.is_triple_quoted() && !literal.flags.prefix().is_raw()
}
FStringPart::FString(string) => {
!string.flags.is_triple_quoted() && !string.flags.prefix().is_raw()
}
});
if can_join {
#[derive(Default)]
struct Collector {
elements: Vec<FStringElement>,
}
impl Collector {
// The logic for concatenating adjacent string literals
// occurs here, implicitly: when we encounter a sequence
// of string literals, the first gets pushed to the
// `elements` vector, while subsequent strings
// are concatenated onto this top string.
fn push_literal(&mut self, literal: &str, range: TextRange) {
if let Some(FStringElement::Literal(existing_literal)) =
self.elements.last_mut()
{
let value = std::mem::take(&mut existing_literal.value);
let mut value = value.into_string();
value.push_str(literal);
existing_literal.value = value.into_boxed_str();
existing_literal.range =
TextRange::new(existing_literal.start(), range.end());
} else {
self.elements.push(FStringElement::Literal(
FStringLiteralElement {
range,
value: literal.into(),
},
));
}
}
fn push_expression(
&mut self,
expression: ast::FStringExpressionElement,
) {
self.elements.push(FStringElement::Expression(expression));
}
}
let mut collector = Collector::default();
for part in &fstring.value {
match part {
ast::FStringPart::Literal(string_literal) => {
collector
.push_literal(&string_literal.value, string_literal.range);
}
ast::FStringPart::FString(fstring) => {
for element in &fstring.elements {
match element {
ast::FStringElement::Literal(literal) => {
collector
.push_literal(&literal.value, literal.range);
}
ast::FStringElement::Expression(expression) => {
collector.push_expression(expression.clone());
}
}
}
}
}
}
fstring.value = ast::FStringValue::single(ast::FString {
elements: collector.elements.into(),
range: fstring.range,
flags: FStringFlags::default(),
});
}
}
}
_ => {}
}
transformer::walk_expr(self, expr);
}
fn visit_string_literal(&self, string_literal: &mut ast::StringLiteral) { fn visit_string_literal(&self, string_literal: &mut ast::StringLiteral) {
static STRIP_DOC_TESTS: LazyLock<Regex> = LazyLock::new(|| { static STRIP_DOC_TESTS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new( Regex::new(

View file

@ -667,21 +667,21 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share
"Arg #2", "Arg #2",
"Arg #3", "Arg #3",
"Arg #4", "Arg #4",
@@ -315,80 +232,72 @@ @@ -316,79 +233,75 @@
triple_quote_string = """This is a really really really long triple quote string assignment and it should not be touched.""" triple_quote_string = """This is a really really really long triple quote string assignment and it should not be touched."""
-assert some_type_of_boolean_expression, ( assert some_type_of_boolean_expression, (
- "Followed by a really really really long string that is used to provide context to" - "Followed by a really really really long string that is used to provide context to"
- " the AssertionError exception." - " the AssertionError exception."
-) + "Followed by a really really really long string that is used to provide context to the AssertionError exception."
+assert some_type_of_boolean_expression, "Followed by a really really really long string that is used to provide context to the AssertionError exception." )
-assert some_type_of_boolean_expression, ( assert some_type_of_boolean_expression, (
- "Followed by a really really really long string that is used to provide context to" - "Followed by a really really really long string that is used to provide context to"
- " the AssertionError exception, which uses dynamic string {}.".format("formatting") - " the AssertionError exception, which uses dynamic string {}.".format("formatting")
+assert some_type_of_boolean_expression, "Followed by a really really really long string that is used to provide context to the AssertionError exception, which uses dynamic string {}.".format( + "Followed by a really really really long string that is used to provide context to the AssertionError exception, which uses dynamic string {}.".format(
+ "formatting" + "formatting"
+ )
) )
assert some_type_of_boolean_expression, ( assert some_type_of_boolean_expression, (
@ -772,7 +772,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share
x, x,
y, y,
z, z,
@@ -397,7 +306,7 @@ @@ -397,7 +310,7 @@
func_with_bad_parens( func_with_bad_parens(
x, x,
y, y,
@ -781,7 +781,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share
z, z,
) )
@@ -408,50 +317,27 @@ @@ -408,50 +321,27 @@
+ CONCATENATED + CONCATENATED
+ "using the '+' operator." + "using the '+' operator."
) )
@ -813,11 +813,10 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share
+backslashes = "This is a really long string with \"embedded\" double quotes and 'single' quotes that also handles checking for an even number of backslashes \\\\" +backslashes = "This is a really long string with \"embedded\" double quotes and 'single' quotes that also handles checking for an even number of backslashes \\\\"
+backslashes = "This is a really 'long' string with \"embedded double quotes\" and 'single' quotes that also handles checking for an odd number of backslashes \\\", like this...\\\\\\" +backslashes = "This is a really 'long' string with \"embedded double quotes\" and 'single' quotes that also handles checking for an odd number of backslashes \\\", like this...\\\\\\"
-short_string = "Hi there." short_string = "Hi there."
+short_string = "Hi" " there."
-func_call(short_string="Hi there.") -func_call(short_string="Hi there.")
+func_call(short_string=("Hi" " there.")) +func_call(short_string=("Hi there."))
raw_strings = r"Don't" " get" r" merged" " unless they are all raw." raw_strings = r"Don't" " get" r" merged" " unless they are all raw."
@ -841,7 +840,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share
long_unmergable_string_with_pragma = ( long_unmergable_string_with_pragma = (
"This is a really long string that can't be merged because it has a likely pragma at the end" # type: ignore "This is a really long string that can't be merged because it has a likely pragma at the end" # type: ignore
@@ -468,51 +354,24 @@ @@ -468,51 +358,24 @@
" of it." " of it."
) )
@ -902,7 +901,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share
) )
dict_with_lambda_values = { dict_with_lambda_values = {
@@ -524,65 +383,58 @@ @@ -524,65 +387,58 @@
# Complex string concatenations with a method call in the middle. # Complex string concatenations with a method call in the middle.
code = ( code = (
@ -986,7 +985,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share
) )
log.info( log.info(
@@ -590,5 +442,5 @@ @@ -590,5 +446,5 @@
) )
log.info( log.info(
@ -1232,10 +1231,14 @@ pragma_comment_string2 = "Lines which end with an inline pragma comment of the f
triple_quote_string = """This is a really really really long triple quote string assignment and it should not be touched.""" triple_quote_string = """This is a really really really long triple quote string assignment and it should not be touched."""
assert some_type_of_boolean_expression, "Followed by a really really really long string that is used to provide context to the AssertionError exception." assert some_type_of_boolean_expression, (
"Followed by a really really really long string that is used to provide context to the AssertionError exception."
)
assert some_type_of_boolean_expression, "Followed by a really really really long string that is used to provide context to the AssertionError exception, which uses dynamic string {}.".format( assert some_type_of_boolean_expression, (
"Followed by a really really really long string that is used to provide context to the AssertionError exception, which uses dynamic string {}.".format(
"formatting" "formatting"
)
) )
assert some_type_of_boolean_expression, ( assert some_type_of_boolean_expression, (
@ -1326,9 +1329,9 @@ backslashes = "This is a really long string with \"embedded\" double quotes and
backslashes = "This is a really long string with \"embedded\" double quotes and 'single' quotes that also handles checking for an even number of backslashes \\\\" backslashes = "This is a really long string with \"embedded\" double quotes and 'single' quotes that also handles checking for an even number of backslashes \\\\"
backslashes = "This is a really 'long' string with \"embedded double quotes\" and 'single' quotes that also handles checking for an odd number of backslashes \\\", like this...\\\\\\" backslashes = "This is a really 'long' string with \"embedded double quotes\" and 'single' quotes that also handles checking for an odd number of backslashes \\\", like this...\\\\\\"
short_string = "Hi" " there." short_string = "Hi there."
func_call(short_string=("Hi" " there.")) func_call(short_string=("Hi there."))
raw_strings = r"Don't" " get" r" merged" " unless they are all raw." raw_strings = r"Don't" " get" r" merged" " unless they are all raw."

View file

@ -614,26 +614,20 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
), ),
varX, varX,
varY, varY,
@@ -70,9 +69,10 @@ @@ -71,8 +70,9 @@
def foo(xxxx):
for xxx_xxxx, _xxx_xxx, _xxx_xxxxx, xxx_xxxx in xxxx: for xxx_xxxx, _xxx_xxx, _xxx_xxxxx, xxx_xxxx in xxxx:
for xxx in xxx_xxxx: for xxx in xxx_xxxx:
- assert ("x" in xxx) or (xxx in xxx_xxx_xxxxx), ( assert ("x" in xxx) or (xxx in xxx_xxx_xxxxx), (
- "{0} xxxxxxx xx {1}, xxx {1} xx xxx xx xxxx xx xxx xxxx: xxx xxxx {2}" - "{0} xxxxxxx xx {1}, xxx {1} xx xxx xx xxxx xx xxx xxxx: xxx xxxx {2}"
- .format(xxx_xxxx, xxx, xxxxxx.xxxxxxx(xxx_xxx_xxxxx)) - .format(xxx_xxxx, xxx, xxxxxx.xxxxxxx(xxx_xxx_xxxxx))
+ assert ( + "{0} xxxxxxx xx {1}, xxx {1} xx xxx xx xxxx xx xxx xxxx: xxx xxxx {2}".format(
+ ("x" in xxx) or (xxx in xxx_xxx_xxxxx)
+ ), "{0} xxxxxxx xx {1}, xxx {1} xx xxx xx xxxx xx xxx xxxx: xxx xxxx {2}".format(
+ xxx_xxxx, xxx, xxxxxx.xxxxxxx(xxx_xxx_xxxxx) + xxx_xxxx, xxx, xxxxxx.xxxxxxx(xxx_xxx_xxxxx)
+ )
) )
@@ -80,10 +80,11 @@ @@ -83,7 +83,8 @@
def disappearing_comment(): "{{xxx_xxxxxxxxxx_xxxxxxxx}} xxx xxxx {} {{xxxx}} >&2".format(
return (
( # xx -x xxxxxxx xx xxx xxxxxxx.
- "{{xxx_xxxxxxxxxx_xxxxxxxx}} xxx xxxx {} {{xxxx}} >&2".format(
+ "{{xxx_xxxxxxxxxx_xxxxxxxx}} xxx xxxx" " {} {{xxxx}} >&2".format(
"{xxxx} {xxxxxx}" "{xxxx} {xxxxxx}"
if xxxxx.xx_xxxxxxxxxx if xxxxx.xx_xxxxxxxxxx
- else ( # Disappearing Comment - else ( # Disappearing Comment
@ -689,7 +683,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
+ ( + (
+ "xxxxxxxxxx xxxx xx xxxxxx(%x) xx %x xxxx xx xxx %x.xx" + "xxxxxxxxxx xxxx xx xxxxxx(%x) xx %x xxxx xx xxx %x.xx"
+ % (len(self) + 1, xxxx.xxxxxxxxxx, xxxx.xxxxxxxxxx) + % (len(self) + 1, xxxx.xxxxxxxxxx, xxxx.xxxxxxxxxx)
+ ) )
+ + ( + + (
+ " %.3f (%s) to %.3f (%s).\n" + " %.3f (%s) to %.3f (%s).\n"
+ % ( + % (
@ -698,7 +692,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
+ x, + x,
+ xxxx.xxxxxxxxxxxxxx(xx), + xxxx.xxxxxxxxxxxxxx(xx),
+ ) + )
) + )
) )
@ -783,7 +777,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
) )
@@ -232,39 +248,24 @@ @@ -232,36 +248,21 @@
some_dictionary = { some_dictionary = {
"xxxxx006": [ "xxxxx006": [
@ -827,12 +821,8 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
+ ) # xxxx xxxxxxxxxx xxxx xx xxxx xx xxx xxxxxxxx xxxxxx xxxxx. + ) # xxxx xxxxxxxxxx xxxx xx xxxx xx xxx xxxxxxxx xxxxxx xxxxx.
-some_tuple = ("some string", "some string which should be joined") some_tuple = ("some string", "some string which should be joined")
+some_tuple = ("some string", "some string" " which should be joined") @@ -279,34 +280,21 @@
some_commented_string = ( # This comment stays at the top.
"This string is long but not so long that it needs hahahah toooooo be so greatttt"
@@ -279,37 +280,26 @@
) )
lpar_and_rpar_have_comments = func_call( # LPAR Comment lpar_and_rpar_have_comments = func_call( # LPAR Comment
@ -852,33 +842,28 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
- f" {'' if ID is None else ID} | perl -nE 'print if /^{field}:/'" - f" {'' if ID is None else ID} | perl -nE 'print if /^{field}:/'"
-) -)
+cmd_fstring = f"sudo -E deluge-console info --detailed --sort-reverse=time_added {'' if ID is None else ID} | perl -nE 'print if /^{field}:/'" +cmd_fstring = f"sudo -E deluge-console info --detailed --sort-reverse=time_added {'' if ID is None else ID} | perl -nE 'print if /^{field}:/'"
+
+cmd_fstring = f"sudo -E deluge-console info --detailed --sort-reverse=time_added {'{{}}' if ID is None else ID} | perl -nE 'print if /^{field}:/'"
-cmd_fstring = ( -cmd_fstring = (
- "sudo -E deluge-console info --detailed --sort-reverse=time_added" - "sudo -E deluge-console info --detailed --sort-reverse=time_added"
- f" {'{{}}' if ID is None else ID} | perl -nE 'print if /^{field}:/'" - f" {'{{}}' if ID is None else ID} | perl -nE 'print if /^{field}:/'"
-) -)
+cmd_fstring = f"sudo -E deluge-console info --detailed --sort-reverse=time_added {{'' if ID is None else ID}} | perl -nE 'print if /^{field}:/'" +cmd_fstring = f"sudo -E deluge-console info --detailed --sort-reverse=time_added {'{{}}' if ID is None else ID} | perl -nE 'print if /^{field}:/'"
-cmd_fstring = ( -cmd_fstring = (
- "sudo -E deluge-console info --detailed --sort-reverse=time_added {'' if ID is" - "sudo -E deluge-console info --detailed --sort-reverse=time_added {'' if ID is"
- f" None else ID}} | perl -nE 'print if /^{field}:/'" - f" None else ID}} | perl -nE 'print if /^{field}:/'"
-) -)
+fstring = f"This string really doesn't need to be an {{{{fstring}}}}, but this one most certainly, absolutely {does}." +cmd_fstring = f"sudo -E deluge-console info --detailed --sort-reverse=time_added {{'' if ID is None else ID}} | perl -nE 'print if /^{field}:/'"
fstring = ( -fstring = (
- "This string really doesn't need to be an {{fstring}}, but this one most" - "This string really doesn't need to be an {{fstring}}, but this one most"
- f" certainly, absolutely {does}." - f" certainly, absolutely {does}."
+ f"We have to remember to escape {braces}." " Like {these}." f" But not {this}." -)
) +fstring = f"This string really doesn't need to be an {{{{fstring}}}}, but this one most certainly, absolutely {does}."
-fstring = f"We have to remember to escape {braces}. Like {{these}}. But not {this}." fstring = f"We have to remember to escape {braces}. Like {{these}}. But not {this}."
-
class A: @@ -364,10 +352,7 @@
class B:
@@ -364,10 +354,7 @@
def foo(): def foo():
if not hasattr(module, name): if not hasattr(module, name):
raise ValueError( raise ValueError(
@ -890,7 +875,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
% (name, module_name, get_docs_version()) % (name, module_name, get_docs_version())
) )
@@ -382,23 +369,19 @@ @@ -382,23 +367,19 @@
class Step(StepBase): class Step(StepBase):
def who(self): def who(self):
@ -921,7 +906,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
# xxxxx xxxxxxxxxxxx xxxx xxx (xxxxxxxxxxxxxxxx) xx x xxxxxxxxx xx xxxxxx. # xxxxx xxxxxxxxxxxx xxxx xxx (xxxxxxxxxxxxxxxx) xx x xxxxxxxxx xx xxxxxx.
"(x.bbbbbbbbbbbb.xxx != " "(x.bbbbbbbbbbbb.xxx != "
'"xxx:xxx:xxx::cccccccccccc:xxxxxxx-xxxx/xxxxxxxxxxx/xxxxxxxxxxxxxxxxx") && ' '"xxx:xxx:xxx::cccccccccccc:xxxxxxx-xxxx/xxxxxxxxxxx/xxxxxxxxxxxxxxxxx") && '
@@ -409,8 +392,8 @@ @@ -409,8 +390,8 @@
if __name__ == "__main__": if __name__ == "__main__":
for i in range(4, 8): for i in range(4, 8):
cmd = ( cmd = (
@ -932,7 +917,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
) )
@@ -432,9 +415,7 @@ @@ -432,9 +413,7 @@
assert xxxxxxx_xxxx in [ assert xxxxxxx_xxxx in [
x.xxxxx.xxxxxx.xxxxx.xxxxxx, x.xxxxx.xxxxxx.xxxxx.xxxxxx,
x.xxxxx.xxxxxx.xxxxx.xxxx, x.xxxxx.xxxxxx.xxxxx.xxxx,
@ -943,7 +928,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
value.__dict__[key] = ( value.__dict__[key] = (
@@ -449,8 +430,7 @@ @@ -449,8 +428,7 @@
RE_TWO_BACKSLASHES = { RE_TWO_BACKSLASHES = {
"asdf_hjkl_jkl": re.compile( "asdf_hjkl_jkl": re.compile(
@ -953,23 +938,23 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
), ),
} }
@@ -462,13 +442,9 @@ @@ -462,13 +440,9 @@
# We do NOT split on f-string expressions. # We do NOT split on f-string expressions.
print( print(
- "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam." - "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam."
- f" {[f'{i}' for i in range(10)]}" - f" {[f'{i}' for i in range(10)]}"
+ f"Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam. {[f'{i}' for i in range(10)]}" -)
)
-x = ( -x = (
- "This is a long string which contains an f-expr that should not split" - "This is a long string which contains an f-expr that should not split"
- f" {{{[i for i in range(5)]}}}." - f" {{{[i for i in range(5)]}}}."
-) + f"Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam. {[f'{i}' for i in range(10)]}"
)
+x = f"This is a long string which contains an f-expr that should not split {{{[i for i in range(5)]}}}." +x = f"This is a long string which contains an f-expr that should not split {{{[i for i in range(5)]}}}."
# The parens should NOT be removed in this case. # The parens should NOT be removed in this case.
( (
@@ -478,8 +454,8 @@ @@ -478,8 +452,8 @@
# The parens should NOT be removed in this case. # The parens should NOT be removed in this case.
( (
@ -980,7 +965,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
) )
# The parens should NOT be removed in this case. # The parens should NOT be removed in this case.
@@ -513,93 +489,83 @@ @@ -513,93 +487,83 @@
temp_msg = ( temp_msg = (
@ -1110,7 +1095,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
"6. Click on Create Credential at the top." "6. Click on Create Credential at the top."
'7. At the top click the link for "API key".' '7. At the top click the link for "API key".'
"8. No application restrictions are needed. Click Create at the bottom." "8. No application restrictions are needed. Click Create at the bottom."
@@ -608,60 +574,45 @@ @@ -608,7 +572,7 @@
# It shouldn't matter if the string prefixes are capitalized. # It shouldn't matter if the string prefixes are capitalized.
temp_msg = ( temp_msg = (
@ -1119,11 +1104,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
f"{balance: <{bal_len + 5}} " f"{balance: <{bal_len + 5}} "
f"<<{author.display_name}>>\n" f"<<{author.display_name}>>\n"
) )
@@ -617,51 +581,34 @@
-fstring = f"We have to remember to escape {braces}. Like {{these}}. But not {this}."
+fstring = (
+ f"We have to remember to escape {braces}." " Like {these}." f" But not {this}."
+)
welcome_to_programming = R"hello," R" world!" welcome_to_programming = R"hello," R" world!"
@ -1189,21 +1170,14 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:
) )
# Regression test for https://github.com/psf/black/issues/3455. # Regression test for https://github.com/psf/black/issues/3455.
@@ -672,9 +623,11 @@ @@ -674,7 +621,4 @@
}
# Regression test for https://github.com/psf/black/issues/3506. # Regression test for https://github.com/psf/black/issues/3506.
-s = f"With single quote: ' {my_dict['foo']} With double quote: \" {my_dict['bar']}" s = f"With single quote: ' {my_dict['foo']} With double quote: \" {my_dict['bar']}"
-
s = ( -s = (
- "Lorem Ipsum is simply dummy text of the printing and typesetting" - "Lorem Ipsum is simply dummy text of the printing and typesetting"
- f" industry:'{my_dict['foo']}'" - f" industry:'{my_dict['foo']}'"
+ "With single quote: ' " -)
+ f" {my_dict['foo']}"
+ ' With double quote: " '
+ f" {my_dict['bar']}"
)
+
+s = f"Lorem Ipsum is simply dummy text of the printing and typesetting industry:'{my_dict['foo']}'" +s = f"Lorem Ipsum is simply dummy text of the printing and typesetting industry:'{my_dict['foo']}'"
``` ```
@ -1281,18 +1255,18 @@ class A:
def foo(xxxx): def foo(xxxx):
for xxx_xxxx, _xxx_xxx, _xxx_xxxxx, xxx_xxxx in xxxx: for xxx_xxxx, _xxx_xxx, _xxx_xxxxx, xxx_xxxx in xxxx:
for xxx in xxx_xxxx: for xxx in xxx_xxxx:
assert ( assert ("x" in xxx) or (xxx in xxx_xxx_xxxxx), (
("x" in xxx) or (xxx in xxx_xxx_xxxxx) "{0} xxxxxxx xx {1}, xxx {1} xx xxx xx xxxx xx xxx xxxx: xxx xxxx {2}".format(
), "{0} xxxxxxx xx {1}, xxx {1} xx xxx xx xxxx xx xxx xxxx: xxx xxxx {2}".format(
xxx_xxxx, xxx, xxxxxx.xxxxxxx(xxx_xxx_xxxxx) xxx_xxxx, xxx, xxxxxx.xxxxxxx(xxx_xxx_xxxxx)
) )
)
class A: class A:
def disappearing_comment(): def disappearing_comment():
return ( return (
( # xx -x xxxxxxx xx xxx xxxxxxx. ( # xx -x xxxxxxx xx xxx xxxxxxx.
"{{xxx_xxxxxxxxxx_xxxxxxxx}} xxx xxxx" " {} {{xxxx}} >&2".format( "{{xxx_xxxxxxxxxx_xxxxxxxx}} xxx xxxx {} {{xxxx}} >&2".format(
"{xxxx} {xxxxxx}" "{xxxx} {xxxxxx}"
if xxxxx.xx_xxxxxxxxxx if xxxxx.xx_xxxxxxxxxx
# Disappearing Comment # Disappearing Comment
@ -1477,7 +1451,7 @@ def foo():
) # xxxx xxxxxxxxxx xxxx xx xxxx xx xxx xxxxxxxx xxxxxx xxxxx. ) # xxxx xxxxxxxxxx xxxx xx xxxx xx xxx xxxxxxxx xxxxxx xxxxx.
some_tuple = ("some string", "some string" " which should be joined") some_tuple = ("some string", "some string which should be joined")
some_commented_string = ( # This comment stays at the top. some_commented_string = ( # This comment stays at the top.
"This string is long but not so long that it needs hahahah toooooo be so greatttt" "This string is long but not so long that it needs hahahah toooooo be so greatttt"
@ -1508,9 +1482,7 @@ cmd_fstring = f"sudo -E deluge-console info --detailed --sort-reverse=time_added
fstring = f"This string really doesn't need to be an {{{{fstring}}}}, but this one most certainly, absolutely {does}." fstring = f"This string really doesn't need to be an {{{{fstring}}}}, but this one most certainly, absolutely {does}."
fstring = ( fstring = f"We have to remember to escape {braces}. Like {{these}}. But not {this}."
f"We have to remember to escape {braces}." " Like {these}." f" But not {this}."
)
class A: class A:
@ -1791,9 +1763,7 @@ temp_msg = (
f"<<{author.display_name}>>\n" f"<<{author.display_name}>>\n"
) )
fstring = ( fstring = f"We have to remember to escape {braces}. Like {{these}}. But not {this}."
f"We have to remember to escape {braces}." " Like {these}." f" But not {this}."
)
welcome_to_programming = R"hello," R" world!" welcome_to_programming = R"hello," R" world!"
@ -1835,12 +1805,7 @@ a_dict = {
} }
# Regression test for https://github.com/psf/black/issues/3506. # Regression test for https://github.com/psf/black/issues/3506.
s = ( s = f"With single quote: ' {my_dict['foo']} With double quote: \" {my_dict['bar']}"
"With single quote: ' "
f" {my_dict['foo']}"
' With double quote: " '
f" {my_dict['bar']}"
)
s = f"Lorem Ipsum is simply dummy text of the printing and typesetting industry:'{my_dict['foo']}'" s = f"Lorem Ipsum is simply dummy text of the printing and typesetting industry:'{my_dict['foo']}'"
``` ```

View file

@ -45,8 +45,9 @@ def func(
def func( def func(
- argument: "int |" "str", - argument: "int |" "str",
+ argument: ("int |" "str"), -) -> Set["int |" " str"]:
) -> Set["int |" " str"]: + argument: ("int |str"),
+) -> Set["int | str"]:
pass pass
``` ```
@ -76,8 +77,8 @@ def func(
def func( def func(
argument: ("int |" "str"), argument: ("int |str"),
) -> Set["int |" " str"]: ) -> Set["int | str"]:
pass pass
``` ```
@ -111,5 +112,3 @@ def func(
) -> Set["int |" " str"]: ) -> Set["int |" " str"]:
pass pass
``` ```

View file

@ -366,22 +366,13 @@ actual: {some_var}"""
[ [
"""cow """cow
moos""", moos""",
@@ -198,7 +239,7 @@
`--global-option` is reserved to flags like `--verbose` or `--quiet`.
"""
-this_will_become_one_line = "abc"
+this_will_become_one_line = "a" "b" "c"
this_will_stay_on_three_lines = (
"a" # comment
@@ -206,7 +247,9 @@ @@ -206,7 +247,9 @@
"c" "c"
) )
-this_will_also_become_one_line = "abc" # comment -this_will_also_become_one_line = "abc" # comment
+this_will_also_become_one_line = ( # comment +this_will_also_become_one_line = ( # comment
+ "a" "b" "c" + "abc"
+) +)
assert some_var == expected_result, """ assert some_var == expected_result, """
@ -632,7 +623,7 @@ Please use `--build-option` instead,
`--global-option` is reserved to flags like `--verbose` or `--quiet`. `--global-option` is reserved to flags like `--verbose` or `--quiet`.
""" """
this_will_become_one_line = "a" "b" "c" this_will_become_one_line = "abc"
this_will_stay_on_three_lines = ( this_will_stay_on_three_lines = (
"a" # comment "a" # comment
@ -641,7 +632,7 @@ this_will_stay_on_three_lines = (
) )
this_will_also_become_one_line = ( # comment this_will_also_become_one_line = ( # comment
"a" "b" "c" "abc"
) )
assert some_var == expected_result, """ assert some_var == expected_result, """

View file

@ -175,5 +175,3 @@ assert xxxxxxxxx.xxxxxxxxx.xxxxxxxxx(
"xxx {xxxxxxxxx} xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" "xxx {xxxxxxxxx} xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
) )
``` ```

View file

@ -0,0 +1,50 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.py
---
## Input
```python
"diffent '" 'quote "are fine"' # join
# More single quotes
"one single'" "two 'single'" ' two "double"'
# More double quotes
'one double"' 'two "double"' " two 'single'"
# Equal number of single and double quotes
'two "double"' " two 'single'"
f"{'Hy \"User\"'}"
```
## Outputs
### Output 1
```
indent-style = space
line-width = 88
indent-width = 4
quote-style = Preserve
line-ending = LineFeed
magic-trailing-comma = Respect
docstring-code = Disabled
docstring-code-line-width = "dynamic"
preview = Enabled
target_version = Py38
source_type = Python
```
```python
"diffent 'quote \"are fine\"" # join
# More single quotes
"one single'two 'single' two \"double\""
# More double quotes
'one double"two "double" two \'single\''
# Equal number of single and double quotes
'two "double" two \'single\''
f"{'Hy "User"'}"
```

View file

@ -406,4 +406,19 @@ class EC2REPATH:
``` ```
## Preview changes
```diff
--- Stable
+++ Preview
@@ -197,8 +197,8 @@
"dddddddddddddddddddddddddd" % aaaaaaaaaaaa + x
)
-"a" "b" "c" + "d" "e" + "f" "g" + "h" "i" "j"
+"abc" + "de" + "fg" + "hij"
class EC2REPATH:
- f.write("Pathway name" + "\t" "Database Identifier" + "\t" "Source database" + "\n")
+ f.write("Pathway name" + "\tDatabase Identifier" + "\tSource database" + "\n")
```

View file

@ -285,6 +285,33 @@ b"Unicode Escape sequence don't apply to bytes: \N{0x} \u{ABCD} \U{ABCDEFGH}"
``` ```
#### Preview changes
```diff
--- Stable
+++ Preview
@@ -63,9 +63,9 @@
# String continuation
-b"Let's" b"start" b"with" b"a" b"simple" b"example"
+b"Let'sstartwithasimpleexample"
-b"Let's" b"start" b"with" b"a" b"simple" b"example" b"now repeat after me:" b"I am confident" b"I am confident" b"I am confident" b"I am confident" b"I am confident"
+b"Let'sstartwithasimpleexamplenow repeat after me:I am confidentI am confidentI am confidentI am confidentI am confident"
(
b"Let's"
@@ -132,6 +132,6 @@
]
# Parenthesized string continuation with messed up indentation
-{"key": ([], b"a" b"b" b"c")}
+{"key": ([], b"abc")}
b"Unicode Escape sequence don't apply to bytes: \N{0x} \u{ABCD} \U{ABCDEFGH}"
```
### Output 2 ### Output 2
``` ```
indent-style = space indent-style = space
@ -441,4 +468,28 @@ b"Unicode Escape sequence don't apply to bytes: \N{0x} \u{ABCD} \U{ABCDEFGH}"
``` ```
#### Preview changes
```diff
--- Stable
+++ Preview
@@ -63,9 +63,9 @@
# String continuation
-b"Let's" b'start' b'with' b'a' b'simple' b'example'
+b"Let'sstartwithasimpleexample"
-b"Let's" b'start' b'with' b'a' b'simple' b'example' b'now repeat after me:' b'I am confident' b'I am confident' b'I am confident' b'I am confident' b'I am confident'
+b"Let'sstartwithasimpleexamplenow repeat after me:I am confidentI am confidentI am confidentI am confidentI am confident"
(
b"Let's"
@@ -132,6 +132,6 @@
]
# Parenthesized string continuation with messed up indentation
-{'key': ([], b'a' b'b' b'c')}
+{'key': ([], b'abc')}
b"Unicode Escape sequence don't apply to bytes: \N{0x} \u{ABCD} \U{ABCDEFGH}"
```

View file

@ -371,7 +371,7 @@ source_type = Python
``` ```
```python ```python
(f"{one}" f"{two}") (f"{one}{two}")
rf"Not-so-tricky \"quote" rf"Not-so-tricky \"quote"
@ -411,7 +411,7 @@ result_f = (
) )
( (
f"{1}" f"{2}" # comment 3 f"{1}{2}" # comment 3
) )
( (
@ -1097,6 +1097,12 @@ _ = (
```diff ```diff
--- Stable --- Stable
+++ Preview +++ Preview
@@ -1,4 +1,4 @@
-(f"{one}" f"{two}")
+(f"{one}{two}")
rf"Not-so-tricky \"quote"
@@ -6,13 +6,13 @@ @@ -6,13 +6,13 @@
# Regression test for fstrings dropping comments # Regression test for fstrings dropping comments
result_f = ( result_f = (
@ -1115,6 +1121,15 @@ _ = (
" f()\n" " f()\n"
# XXX: The following line changes depending on whether the tests # XXX: The following line changes depending on whether the tests
# are run through the interactive interpreter or with -m # are run through the interactive interpreter or with -m
@@ -38,7 +38,7 @@
)
(
- f"{1}" f"{2}" # comment 3
+ f"{1}{2}" # comment 3
)
(
@@ -67,29 +67,31 @@ @@ -67,29 +67,31 @@
x = f"{a}" x = f"{a}"
x = f"{ x = f"{

View file

@ -0,0 +1,735 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string.py
---
## Input
```python
"aaaaaaaaa" "bbbbbbbbbbbbbbbbbbbb" # Join
(
"aaaaaaaaaaa" "bbbbbbbbbbbbbbbb"
) # join
(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
) # too long to join
"different '" 'quote "are fine"' # join
# More single quotes
"one single'" "two 'single'" ' two "double"'
# More double quotes
'one double"' 'two "double"' " two 'single'"
# Equal number of single and double quotes
'two "double"' " two 'single'"
f"{'Hy \"User\"'}" 'more'
b"aaaaaaaaa" b"bbbbbbbbbbbbbbbbbbbb" # Join
(
b"aaaaaaaaaaa" b"bbbbbbbbbbbbbbbb"
) # join
(
b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" b"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
) # too long to join
# Skip joining if there is a trailing comment
(
"fffffffffffff"
"bbbbbbbbbbbbb" # comment
"cccccccccccccc"
)
# Skip joining if there is a leading comment
(
"fffffffffffff"
# comment
"bbbbbbbbbbbbb"
"cccccccccccccc"
)
##############################################################################
# F-strings
##############################################################################
# Escape `{` and `}` when marging an f-string with a string
"a {not_a_variable}" f"b {10}" "c"
# Join, and break expressions
f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa{
expression
}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" f"cccccccccccccccccccc {20999}" "more"
# Join, but don't break the expressions
f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa{expression}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" f"cccccccccccccccccccc {20999}" "more"
f"test{
expression
}flat" f"can be {
joined
} together"
aaaaaaaaaaa = f"test{
expression
}flat" f"cean beeeeeeee {
joined
} eeeeeeeeeeeeeeeeeeeeeeeeeeeee" # inline
f"single quoted '{x}'" f'double quoted "{x}"' # Same number of quotes => use preferred quote style
f"single quote ' {x}" f'double quoted "{x}"' # More double quotes => use single quotes
f"single quoted '{x}'" f'double quote " {x}"' # More single quotes => use double quotes
# Different triple quoted strings
f"{'''test'''}" f'{"""other"""}'
# Now with inner quotes
f"{'''test ' '''}" f'{"""other " """}'
f"{some_where_nested('''test ' ''')}" f'{"""other " """ + "more"}'
f"{b'''test ' '''}" f'{b"""other " """}'
f"{f'''test ' '''}" f'{f"""other " """}'
# debug expressions containing quotes
f"{10 + len('bar')=}" f"{10 + len('bar')=}"
f"{10 + len('bar')=}" f'no debug{10}' f"{10 + len('bar')=}"
# We can't savely merge this pre Python 3.12 without altering the debug expression.
f"{10 + len('bar')=}" f'{10 + len("bar")=}'
##############################################################################
# Don't join raw strings
##############################################################################
r"a" "normal"
R"a" "normal"
f"test" fr"test"
f"test" fR"test"
##############################################################################
# Don't join triple quoted strings
##############################################################################
"single" """triple"""
"single" f""""single"""
b"single" b"""triple"""
##############################################################################
# Join strings in with statements
##############################################################################
# Fits
with "aa" "bbb" "cccccccccccccccccccccccccccccccccccccccccccccc":
pass
# Parenthesize single-line
with "aa" "bbb" "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc":
pass
# Multiline
with "aa" "bbb" "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc":
pass
with f"aaaaaaa{expression}bbbb" f"ccc {20999}" "more":
pass
##############################################################################
# For loops
##############################################################################
# Flat
for a in "aaaaaaaaa" "bbbbbbbbb" "ccccccccc" "dddddddddd":
pass
# Parenthesize single-line
for a in "aaaaaaaaa" "bbbbbbbbb" "ccccccccc" "dddddddddd" "eeeeeeeeeeeeeee" "fffffffffffff" "ggggggggggggggg" "hh":
pass
# Multiline
for a in "aaaaaaaaa" "bbbbbbbbb" "ccccccccc" "dddddddddd" "eeeeeeeeeeeeeee" "fffffffffffff" "ggggggggggggggg" "hhhh":
pass
##############################################################################
# Assert statement
##############################################################################
# Fits
assert "aaaaaaaaa" "bbbbbbbbbbbb", "cccccccccccccccc" "dddddddddddddddd"
# Wrap right
assert "aaaaaaaaa" "bbbbbbbbbbbb", "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffff"
# Right multiline
assert "aaaaaaaaa" "bbbbbbbbbbbb", "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff" "ggggggggggggg" "hhhhhhhhhhh"
# Wrap left
assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff", "ggggggggggggg" "hhhhhhhhhhh"
# Left multiline
assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff" "ggggggggggggg", "hhhhhhhhhhh"
# wrap both
assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff", "ggggggggggggg" "hhhhhhhhhhh" "iiiiiiiiiiiiiiiiii" "jjjjjjjjjjjjj" "kkkkkkkkkkkkkkkkk" "llllllllllll"
# both multiline
assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff" "ggggggggggggg", "hhhhhhhhhhh" "iiiiiiiiiiiiiiiiii" "jjjjjjjjjjjjj" "kkkkkkkkkkkkkkkkk" "llllllllllll" "mmmmmmmmmmmmmm"
##############################################################################
# In clause headers (can_omit_optional_parentheses)
##############################################################################
# Use can_omit_optional_parentheses layout to avoid an instability where the formatter
# picks the can_omit_optional_parentheses layout when the strings are joined.
if (
f"implicit"
"concatenated"
"string" + f"implicit"
"concaddddddddddded"
"ring"
* len([aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd])
):
pass
# Keep parenthesizing multiline - implicit concatenated strings
if (
f"implicit"
"""concatenate
d"""
"string" + f"implicit"
"concaddddddddddded"
"ring"
* len([aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd])
):
pass
if (
[
aaaaaa,
bbbbbbbbbbbbbbbb,
cccccccccccccccccc,
ddddddddddddddddddddddddddd,
]
+ "implicitconcat"
"enatedstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing"
):
pass
# In match statements
match x:
case "implicitconcat" "enatedstring" | [
aaaaaa,
bbbbbbbbbbbbbbbb,
cccccccccccccccccc,
ddddddddddddddddddddddddddd,
]:
pass
case [
aaaaaa,
bbbbbbbbbbbbbbbb,
cccccccccccccccccc,
ddddddddddddddddddddddddddd,
] | "implicitconcat" "enatedstring" :
pass
case "implicitconcat" "enatedstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing" | [
aaaaaa,
bbbbbbbbbbbbbbbb,
cccccccccccccccccc,
ddddddddddddddddddddddddddd,
]:
pass
##############################################################################
# In docstring positions
##############################################################################
def short_docstring():
"Implicit" "concatenated" "docstring"
def long_docstring():
"Loooooooooooooooooooooong" "doooooooooooooooooooocstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing" "exceding the line width" "but it should be concatenated anyways because it is single line"
def docstring_with_leading_whitespace():
" This is a " "implicit" "concatenated" "docstring"
def docstring_with_trailing_whitespace():
"This is a " "implicit" "concatenated" "docstring "
def docstring_with_leading_empty_parts():
" " " " "" "This is a " "implicit" "concatenated" "docstring"
def docstring_with_trailing_empty_parts():
"This is a " "implicit" "concatenated" "docstring" "" " " " "
def all_empty():
" " " " " "
def byte_string_in_docstring_position():
b" don't trim the" b"bytes literal "
def f_string_in_docstring_position():
f" don't trim the" "f-string literal "
def single_quoted():
' content\ ' ' '
return
def implicit_with_comment():
(
"a"
# leading
"the comment above"
)
##############################################################################
# Regressions
##############################################################################
LEEEEEEEEEEEEEEEEEEEEEEFT = RRRRRRRRIIIIIIIIIIIIGGGGGHHHT | {
"entityNameeeeeeeeeeeeeeeeee", # comment must be long enough to
"some long implicit concatenated string" "that should join"
}
# Ensure that flipping between Multiline and BestFit layout results in stable formatting
# when using IfBreaksParenthesized layout.
assert False, "Implicit concatenated string" "uses {} layout on {} format".format(
"Multiline", "first"
)
assert False, await "Implicit concatenated string" "uses {} layout on {} format".format(
"Multiline", "first"
)
assert False, "Implicit concatenated stringuses {} layout on {} format"[
aaaaaaaaa, bbbbbb
]
assert False, +"Implicit concatenated string" "uses {} layout on {} format".format(
"Multiline", "first"
)
```
## Outputs
### Output 1
```
indent-style = space
line-width = 88
indent-width = 4
quote-style = Double
line-ending = LineFeed
magic-trailing-comma = Respect
docstring-code = Disabled
docstring-code-line-width = "dynamic"
preview = Enabled
target_version = Py38
source_type = Python
```
```python
"aaaaaaaaabbbbbbbbbbbbbbbbbbbb" # Join
("aaaaaaaaaaabbbbbbbbbbbbbbbb") # join
(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
) # too long to join
'different \'quote "are fine"' # join
# More single quotes
"one single'two 'single' two \"double\""
# More double quotes
'one double"two "double" two \'single\''
# Equal number of single and double quotes
"two \"double\" two 'single'"
f"{'Hy "User"'}more"
b"aaaaaaaaabbbbbbbbbbbbbbbbbbbb" # Join
(b"aaaaaaaaaaabbbbbbbbbbbbbbbb") # join
(
b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
b"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
) # too long to join
# Skip joining if there is a trailing comment
(
"fffffffffffff"
"bbbbbbbbbbbbb" # comment
"cccccccccccccc"
)
# Skip joining if there is a leading comment
(
"fffffffffffff"
# comment
"bbbbbbbbbbbbb"
"cccccccccccccc"
)
##############################################################################
# F-strings
##############################################################################
# Escape `{` and `}` when marging an f-string with a string
f"a {{not_a_variable}}b {10}c"
# Join, and break expressions
f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa{
expression
}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbcccccccccccccccccccc {20999}more"
# Join, but don't break the expressions
f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa{expression}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbcccccccccccccccccccc {20999}more"
f"test{expression}flatcan be {joined} together"
aaaaaaaaaaa = (
f"test{expression}flat"
f"cean beeeeeeee {joined} eeeeeeeeeeeeeeeeeeeeeeeeeeeee"
) # inline
f"single quoted '{x}'double quoted \"{x}\"" # Same number of quotes => use preferred quote style
f'single quote \' {x}double quoted "{x}"' # More double quotes => use single quotes
f"single quoted '{x}'double quote \" {x}\"" # More single quotes => use double quotes
# Different triple quoted strings
f"{'''test'''}{'''other'''}"
# Now with inner quotes
f"{'''test ' '''}" f'{"""other " """}'
f"{some_where_nested('''test ' ''')}" f'{"""other " """ + "more"}'
f"{b'''test ' '''}" f'{b"""other " """}'
f"{f'''test ' '''}" f'{f"""other " """}'
# debug expressions containing quotes
f"{10 + len('bar')=}{10 + len('bar')=}"
f"{10 + len('bar')=}no debug{10}{10 + len('bar')=}"
# We can't savely merge this pre Python 3.12 without altering the debug expression.
f"{10 + len('bar')=}" f'{10 + len("bar")=}'
##############################################################################
# Don't join raw strings
##############################################################################
r"a" "normal"
R"a" "normal"
f"test" rf"test"
f"test" Rf"test"
##############################################################################
# Don't join triple quoted strings
##############################################################################
"single" """triple"""
"single" f""""single"""
b"single" b"""triple"""
##############################################################################
# Join strings in with statements
##############################################################################
# Fits
with "aabbbcccccccccccccccccccccccccccccccccccccccccccccc":
pass
# Parenthesize single-line
with (
"aabbbccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
):
pass
# Multiline
with (
"aa"
"bbb"
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
):
pass
with f"aaaaaaa{expression}bbbbccc {20999}more":
pass
##############################################################################
# For loops
##############################################################################
# Flat
for a in "aaaaaaaaabbbbbbbbbcccccccccdddddddddd":
pass
# Parenthesize single-line
for a in (
"aaaaaaaaabbbbbbbbbcccccccccddddddddddeeeeeeeeeeeeeeefffffffffffffggggggggggggggghh"
):
pass
# Multiline
for a in (
"aaaaaaaaa"
"bbbbbbbbb"
"ccccccccc"
"dddddddddd"
"eeeeeeeeeeeeeee"
"fffffffffffff"
"ggggggggggggggg"
"hhhh"
):
pass
##############################################################################
# Assert statement
##############################################################################
# Fits
assert "aaaaaaaaabbbbbbbbbbbb", "ccccccccccccccccdddddddddddddddd"
# Wrap right
assert "aaaaaaaaabbbbbbbbbbbb", (
"ccccccccccccccccddddddddddddddddeeeeeeeeeeeeefffffffffff"
)
# Right multiline
assert "aaaaaaaaabbbbbbbbbbbb", (
"cccccccccccccccc"
"dddddddddddddddd"
"eeeeeeeeeeeee"
"fffffffffffffff"
"ggggggggggggg"
"hhhhhhhhhhh"
)
# Wrap left
assert (
"aaaaaaaaabbbbbbbbbbbbccccccccccccccccddddddddddddddddeeeeeeeeeeeeefffffffffffffff"
), "ggggggggggggghhhhhhhhhhh"
# Left multiline
assert (
"aaaaaaaaa"
"bbbbbbbbbbbb"
"cccccccccccccccc"
"dddddddddddddddd"
"eeeeeeeeeeeee"
"fffffffffffffff"
"ggggggggggggg"
), "hhhhhhhhhhh"
# wrap both
assert (
"aaaaaaaaabbbbbbbbbbbbccccccccccccccccddddddddddddddddeeeeeeeeeeeeefffffffffffffff"
), (
"ggggggggggggg"
"hhhhhhhhhhh"
"iiiiiiiiiiiiiiiiii"
"jjjjjjjjjjjjj"
"kkkkkkkkkkkkkkkkk"
"llllllllllll"
)
# both multiline
assert (
"aaaaaaaaa"
"bbbbbbbbbbbb"
"cccccccccccccccc"
"dddddddddddddddd"
"eeeeeeeeeeeee"
"fffffffffffffff"
"ggggggggggggg"
), (
"hhhhhhhhhhh"
"iiiiiiiiiiiiiiiiii"
"jjjjjjjjjjjjj"
"kkkkkkkkkkkkkkkkk"
"llllllllllll"
"mmmmmmmmmmmmmm"
)
##############################################################################
# In clause headers (can_omit_optional_parentheses)
##############################################################################
# Use can_omit_optional_parentheses layout to avoid an instability where the formatter
# picks the can_omit_optional_parentheses layout when the strings are joined.
if f"implicitconcatenatedstring" + f"implicitconcadddddddddddedring" * len([
aaaaaa,
bbbbbbbbbbbbbbbb,
cccccccccccccccccc,
ddddddddddddddddddddddddddd,
]):
pass
# Keep parenthesizing multiline - implicit concatenated strings
if (
f"implicit"
"""concatenate
d"""
"string" + f"implicit"
"concaddddddddddded"
"ring"
* len([aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd])
):
pass
if (
[
aaaaaa,
bbbbbbbbbbbbbbbb,
cccccccccccccccccc,
ddddddddddddddddddddddddddd,
]
+ "implicitconcat"
"enatedstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing"
):
pass
# In match statements
match x:
case "implicitconcatenatedstring" | [
aaaaaa,
bbbbbbbbbbbbbbbb,
cccccccccccccccccc,
ddddddddddddddddddddddddddd,
]:
pass
case [
aaaaaa,
bbbbbbbbbbbbbbbb,
cccccccccccccccccc,
ddddddddddddddddddddddddddd,
] | "implicitconcatenatedstring":
pass
case (
"implicitconcat"
"enatedstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing"
| [
aaaaaa,
bbbbbbbbbbbbbbbb,
cccccccccccccccccc,
ddddddddddddddddddddddddddd,
]
):
pass
##############################################################################
# In docstring positions
##############################################################################
def short_docstring():
"Implicitconcatenateddocstring"
def long_docstring():
"Loooooooooooooooooooooongdoooooooooooooooooooocstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiingexceding the line widthbut it should be concatenated anyways because it is single line"
def docstring_with_leading_whitespace():
"This is a implicitconcatenateddocstring"
def docstring_with_trailing_whitespace():
"This is a implicitconcatenateddocstring"
def docstring_with_leading_empty_parts():
"This is a implicitconcatenateddocstring"
def docstring_with_trailing_empty_parts():
"This is a implicitconcatenateddocstring"
def all_empty():
""
def byte_string_in_docstring_position():
b" don't trim thebytes literal "
def f_string_in_docstring_position():
f" don't trim thef-string literal "
def single_quoted():
"content\ "
return
def implicit_with_comment():
(
"a"
# leading
"the comment above"
)
##############################################################################
# Regressions
##############################################################################
LEEEEEEEEEEEEEEEEEEEEEEFT = RRRRRRRRIIIIIIIIIIIIGGGGGHHHT | {
"entityNameeeeeeeeeeeeeeeeee", # comment must be long enough to
"some long implicit concatenated stringthat should join",
}
# Ensure that flipping between Multiline and BestFit layout results in stable formatting
# when using IfBreaksParenthesized layout.
assert False, "Implicit concatenated stringuses {} layout on {} format".format(
"Multiline", "first"
)
assert False, await "Implicit concatenated stringuses {} layout on {} format".format(
"Multiline", "first"
)
assert False, "Implicit concatenated stringuses {} layout on {} format"[
aaaaaaaaa, bbbbbb
]
assert False, +"Implicit concatenated stringuses {} layout on {} format".format(
"Multiline", "first"
)
```

View file

@ -0,0 +1,637 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_assignment.py
---
## Input
```python
## Implicit concatenated strings with a trailing comment but a non splittable target.
# Don't join the string because the joined string with the inlined comment exceeds the line length limit.
____aaa = (
"aaaaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv"
) # c
# This is the same string as above and should lead to the same formatting. The only difference is that we start
# with an unparenthesized string.
____aaa = "aaaaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" # c
# Again the same string as above but this time as non-implicit concatenated string.
# It's okay if the formatting differs because it's an explicit choice to use implicit concatenation.
____aaa = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" # c
# Join the string because it's exactly in the line length limit when the comment is inlined.
____aaa = (
"aaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv"
) # c
# This is the same string as above and should lead to the same formatting. The only difference is that we start
# with an unparenthesized string.
____aaa = "aaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" # c
# Again the same string as above but as a non-implicit concatenated string. It should result in the same formatting
# (for consistency).
____aaa = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" # c
# It should collapse the parentheses if the joined string and the comment fit on the same line.
# This is required for stability.
____aaa = (
"aaaaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvv" # c
)
#############################################################
# Assignments where the target or annotations are splittable
#############################################################
# The target splits because of a magic trailing comma
# The string is joined and not parenthesized because it just fits into the line length (including comment).
a[
aaaaaaa,
b,
] = "ccccccccccccccccccccccccccccc" "cccccccccccccccccccccccccccccccccccccccccc" # comment
# Same but starting with a joined string. They should both result in the same formatting.
[
aaaaaaa,
b,
] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment
# The target splits because of the magic trailing comma
# The string is **not** joined because it with the inlined comment exceeds the line length limit.
a[
aaaaaaa,
b,
] = "ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" # comment
# The target should be flat
# The string should be joined because it fits into the line length
a[
aaaaaaa,
b
] = (
"ccccccccccccccccccccccccccccccccccc" "cccccccccccccccccccccccc" # comment
)
# Same but starting with a joined string. They should both result in the same formatting.
a[
aaaaaaa,
b
] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment
# The target should be flat
# The string gets parenthesized because it, with the inlined comment, exceeds the line length limit.
a[
aaaaaaa,
b
] = "ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" # comment
# Split an overlong target, but join the string if it fits
a[
aaaaaaa,
b
].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = (
"ccccccccccccccccccccccccccccccccccccccccc" "cccccccccccccccccccccccccccccc" # comment
)
# Split both if necessary and keep multiline
a[
aaaaaaa,
b
].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = (
"ccccccccccccccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccc" # comment
)
#########################################################
# Leading or trailing own line comments:
# Preserve the parentheses
########################################################
a[
aaaaaaa,
b
] = (
# test
"ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc"
)
a[
aaaaaaa,
b
] = (
"ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc"
# test
)
a[
aaaaaaa,
b
] = (
"ccccccccccccccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc"
# test
)
#############################################################
# Type alias statements
#############################################################
# First break the right, join the string
type A[str, int, number] = "Literal[string, int] | None | " "CustomType" "| OtherCustomTypeExcee" # comment
# Keep multiline if overlong
type A[str, int, number] = "Literal[string, int] | None | " "CustomTypeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" # comment
# Break the left if it is over-long, join the string
type Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[stringgggggggggg, inttttttttttttttttttttttt, number] = "Literal[string, int] | None | " "CustomType" # comment
# Break both if necessary and keep multiline
type Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[stringgggggggggg, inttttttttttttttttttttttt, number] = "Literal[string, int] | None | " "CustomTypeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" # comment
#############################################################
# F-Strings
#############################################################
# Flatten and join the f-string
aaaaaaaaaaa = f"test{
expression}flat" f"cean beeeeeeee {joined} eeeeeeeeeeeeeeeee" # inline
# Parenthesize the value and join it, inline the comment
aaaaaaaaaaa = f"test{
expression}flat" f"cean beeeeeeee {joined} eeeeeeeeeeeeeeeeeeeeeeeeeee" # inline
# Parenthesize the f-string and keep it multiline because it doesn't fit on a single line including the comment
aaaaaaaaaaa = f"test{
expression
}flat" f"cean beeeeeeee {
joined
} eeeeeeeeeeeeeeeeeeeeeeeeeeeee" # inline
# The target splits because of a magic trailing comma
# The string is joined and not parenthesized because it just fits into the line length (including comment).
a[
aaaaaaa,
b,
] = f"ccccc{
expression}ccccccccccc" f"cccccccccccccccccccccccccccccccccccccccccc" # comment
# Same but starting with a joined string. They should both result in the same formatting.
[
aaaaaaa,
b,
] = f"ccccc{
expression}ccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment
# The target splits because of the magic trailing comma
# The string is **not** joined because it with the inlined comment exceeds the line length limit.
a[
aaaaaaa,
b,
] = f"ccccc{
expression}cccccccccccccccccccc" f"cccccccccccccccccccccccccccccccccccccccccc" # comment
# The target should be flat
# The string should be joined because it fits into the line length
a[
aaaaaaa,
b
] = (
f"ccccc{
expression}ccccccccccc" "cccccccccccccccccccccccc" # comment
)
# Same but starting with a joined string. They should both result in the same formatting.
a[
aaaaaaa,
b
] = f"ccccc{
expression}ccccccccccccccccccccccccccccccccccc" # comment
# The target should be flat
# The string gets parenthesized because it, with the inlined comment, exceeds the line length limit.
a[
aaaaaaa,
b
] = f"ccccc{
expression}ccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" # comment
# Split an overlong target, but join the string if it fits
a[
aaaaaaa,
b
].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = (
f"ccccc{
expression}ccccccccccc" "cccccccccccccccccccccccccccccc" # comment
)
# Split both if necessary and keep multiline
a[
aaaaaaa,
b
].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = (
f"ccccc{
expression}cccccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccc" # comment
)
# Don't inline f-strings that contain expressions that are guaranteed to split, e.b. because of a magic trailing comma
aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[a,]
}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[a,]
}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment
)
aaaaa[aaaaaaaaaaa] = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[a,]
}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment
aaaaa[aaaaaaaaaaa] = (f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[a,]
}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment
)
# Don't inline f-strings that contain commented expressions
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{[
a # comment
]}" "moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaa[aaaaaaaaaaa] = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{[
a # comment
]}" "moreeeeeeeeeeeeeeeeeetest" # comment
)
# Don't inline f-strings with multiline debug expressions:
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{
a=}" "moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{a +
b=}" "moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{a
=}" "moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaa[aaaaaaaaaaa] = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{
a=}" "moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaa[aaaaaaaaaaa] = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{a
=}" "moreeeeeeeeeeeeeeeeeetest" # comment
)
```
## Outputs
### Output 1
```
indent-style = space
line-width = 88
indent-width = 4
quote-style = Double
line-ending = LineFeed
magic-trailing-comma = Respect
docstring-code = Disabled
docstring-code-line-width = "dynamic"
preview = Enabled
target_version = Py38
source_type = Python
```
```python
## Implicit concatenated strings with a trailing comment but a non splittable target.
# Don't join the string because the joined string with the inlined comment exceeds the line length limit.
____aaa = (
"aaaaaaaaaaaaaaaaaaaaa"
"aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv"
) # c
# This is the same string as above and should lead to the same formatting. The only difference is that we start
# with an unparenthesized string.
____aaa = (
"aaaaaaaaaaaaaaaaaaaaa"
"aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv"
) # c
# Again the same string as above but this time as non-implicit concatenated string.
# It's okay if the formatting differs because it's an explicit choice to use implicit concatenation.
____aaa = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" # c
# Join the string because it's exactly in the line length limit when the comment is inlined.
____aaa = (
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" # c
)
# This is the same string as above and should lead to the same formatting. The only difference is that we start
# with an unparenthesized string.
____aaa = (
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" # c
)
# Again the same string as above but as a non-implicit concatenated string. It should result in the same formatting
# (for consistency).
____aaa = (
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" # c
)
# It should collapse the parentheses if the joined string and the comment fit on the same line.
# This is required for stability.
____aaa = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvv" # c
#############################################################
# Assignments where the target or annotations are splittable
#############################################################
# The target splits because of a magic trailing comma
# The string is joined and not parenthesized because it just fits into the line length (including comment).
a[
aaaaaaa,
b,
] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment
# Same but starting with a joined string. They should both result in the same formatting.
[
aaaaaaa,
b,
] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment
# The target splits because of the magic trailing comma
# The string is **not** joined because it with the inlined comment exceeds the line length limit.
a[
aaaaaaa,
b,
] = (
"ccccccccccccccccccccccccccccc"
"ccccccccccccccccccccccccccccccccccccccccccc"
) # comment
# The target should be flat
# The string should be joined because it fits into the line length
a[aaaaaaa, b] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment
# Same but starting with a joined string. They should both result in the same formatting.
a[aaaaaaa, b] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment
# The target should be flat
# The string gets parenthesized because it, with the inlined comment, exceeds the line length limit.
a[aaaaaaa, b] = (
"ccccccccccccccccccccccccccccc"
"ccccccccccccccccccccccccccccccccccccccccccc"
) # comment
# Split an overlong target, but join the string if it fits
a[
aaaaaaa, b
].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = (
"ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment
)
# Split both if necessary and keep multiline
a[
aaaaaaa, b
].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = (
"ccccccccccccccccccccccccccccccccccccccccc"
"ccccccccccccccccccccccccccccccc"
) # comment
#########################################################
# Leading or trailing own line comments:
# Preserve the parentheses
########################################################
a[aaaaaaa, b] = (
# test
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
)
a[aaaaaaa, b] = (
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
# test
)
a[aaaaaaa, b] = (
"ccccccccccccccccccccccccccccccccccccccccc"
"ccccccccccccccccccccccccccccccccccccccccccc"
# test
)
#############################################################
# Type alias statements
#############################################################
# First break the right, join the string
type A[str, int, number] = (
"Literal[string, int] | None | CustomType| OtherCustomTypeExcee" # comment
)
# Keep multiline if overlong
type A[str, int, number] = (
"Literal[string, int] | None | "
"CustomTypeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
) # comment
# Break the left if it is over-long, join the string
type Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[
stringgggggggggg,
inttttttttttttttttttttttt,
number,
] = "Literal[string, int] | None | CustomType" # comment
# Break both if necessary and keep multiline
type Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[
stringgggggggggg,
inttttttttttttttttttttttt,
number,
] = (
"Literal[string, int] | None | "
"CustomTypeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
) # comment
#############################################################
# F-Strings
#############################################################
# Flatten and join the f-string
aaaaaaaaaaa = f"test{expression}flatcean beeeeeeee {joined} eeeeeeeeeeeeeeeee" # inline
# Parenthesize the value and join it, inline the comment
aaaaaaaaaaa = (
f"test{expression}flatcean beeeeeeee {joined} eeeeeeeeeeeeeeeeeeeeeeeeeee" # inline
)
# Parenthesize the f-string and keep it multiline because it doesn't fit on a single line including the comment
aaaaaaaaaaa = (
f"test{expression}flat"
f"cean beeeeeeee {joined} eeeeeeeeeeeeeeeeeeeeeeeeeeeee"
) # inline
# The target splits because of a magic trailing comma
# The string is joined and not parenthesized because it just fits into the line length (including comment).
a[
aaaaaaa,
b,
] = f"ccccc{expression}ccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment
# Same but starting with a joined string. They should both result in the same formatting.
[
aaaaaaa,
b,
] = f"ccccc{expression}ccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment
# The target splits because of the magic trailing comma
# The string is **not** joined because it with the inlined comment exceeds the line length limit.
a[
aaaaaaa,
b,
] = (
f"ccccc{expression}cccccccccccccccccccc"
f"cccccccccccccccccccccccccccccccccccccccccc"
) # comment
# The target should be flat
# The string should be joined because it fits into the line length
a[aaaaaaa, b] = f"ccccc{expression}ccccccccccccccccccccccccccccccccccc" # comment
# Same but starting with a joined string. They should both result in the same formatting.
a[aaaaaaa, b] = f"ccccc{expression}ccccccccccccccccccccccccccccccccccc" # comment
# The target should be flat
# The string gets parenthesized because it, with the inlined comment, exceeds the line length limit.
a[aaaaaaa, b] = (
f"ccccc{expression}ccccccccccc"
"ccccccccccccccccccccccccccccccccccccccccccc"
) # comment
# Split an overlong target, but join the string if it fits
a[
aaaaaaa, b
].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = (
f"ccccc{expression}ccccccccccccccccccccccccccccccccccccccccc" # comment
)
# Split both if necessary and keep multiline
a[
aaaaaaa, b
].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = (
f"ccccc{expression}cccccccccccccccccccccccccccccccc"
"ccccccccccccccccccccccccccccccc"
) # comment
# Don't inline f-strings that contain expressions that are guaranteed to split, e.b. because of a magic trailing comma
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[
a,
]
}"
"moreeeeeeeeeeeeeeeeeeee"
"test"
) # comment
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[
a,
]
}"
"moreeeeeeeeeeeeeeeeeeee"
"test" # comment
)
aaaaa[aaaaaaaaaaa] = (
f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[
a,
]
}"
"moreeeeeeeeeeeeeeeeeeee"
"test"
) # comment
aaaaa[aaaaaaaaaaa] = (
f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
[
a,
]
}"
"moreeeeeeeeeeeeeeeeeeee"
"test" # comment
)
# Don't inline f-strings that contain commented expressions
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{
[
a # comment
]
}"
"moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaa[aaaaaaaaaaa] = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{
[
a # comment
]
}"
"moreeeeeeeeeeeeeeeeeetest" # comment
)
# Don't inline f-strings with multiline debug expressions:
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{
a=}"
"moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{a +
b=}"
"moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaaaaaaaaaaaaaaa = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{a
=}"
"moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaa[aaaaaaaaaaa] = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{
a=}"
"moreeeeeeeeeeeeeeeeeetest" # comment
)
aaaaa[aaaaaaaaaaa] = (
f"testeeeeeeeeeeeeeeeeeeeeeeeee{a
=}"
"moreeeeeeeeeeeeeeeeeetest" # comment
)
```

View file

@ -0,0 +1,84 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.py
---
## Input
```python
a = "different '" 'quote "are fine"' # join
# More single quotes
"one single'" "two 'single'" ' two "double"'
# More double quotes
'one double"' 'two "double"' " two 'single'"
# Equal number of single and double quotes
'two "double"' " two 'single'"
# Already invalid Pre Python 312
f"{'Hy "User"'}" f'{"Hy 'User'"}'
```
## Outputs
### Output 1
```
indent-style = space
line-width = 88
indent-width = 4
quote-style = Preserve
line-ending = LineFeed
magic-trailing-comma = Respect
docstring-code = Disabled
docstring-code-line-width = "dynamic"
preview = Enabled
target_version = Py38
source_type = Python
```
```python
a = "different 'quote \"are fine\"" # join
# More single quotes
"one single'two 'single' two \"double\""
# More double quotes
'one double"two "double" two \'single\''
# Equal number of single and double quotes
'two "double" two \'single\''
# Already invalid Pre Python 312
f"{'Hy "User"'}{"Hy 'User'"}"
```
### Output 2
```
indent-style = space
line-width = 88
indent-width = 4
quote-style = Preserve
line-ending = LineFeed
magic-trailing-comma = Respect
docstring-code = Disabled
docstring-code-line-width = "dynamic"
preview = Enabled
target_version = Py312
source_type = Python
```
```python
a = "different 'quote \"are fine\"" # join
# More single quotes
"one single'two 'single' two \"double\""
# More double quotes
'one double"two "double" two \'single\''
# Equal number of single and double quotes
'two "double" two \'single\''
# Already invalid Pre Python 312
f"{'Hy "User"'}{"Hy 'User'"}"
```

View file

@ -331,6 +331,34 @@ a = """\\\x1f"""
``` ```
#### Preview changes
```diff
--- Stable
+++ Preview
@@ -70,9 +70,9 @@
# String continuation
-"Let's" "start" "with" "a" "simple" "example"
+"Let'sstartwithasimpleexample"
-"Let's" "start" "with" "a" "simple" "example" "now repeat after me:" "I am confident" "I am confident" "I am confident" "I am confident" "I am confident"
+"Let'sstartwithasimpleexamplenow repeat after me:I am confidentI am confidentI am confidentI am confidentI am confident"
(
"Let's"
@@ -139,7 +139,7 @@
]
# Parenthesized string continuation with messed up indentation
-{"key": ([], "a" "b" "c")}
+{"key": ([], "abc")}
# Regression test for https://github.com/astral-sh/ruff/issues/5893
```
### Output 2 ### Output 2
``` ```
indent-style = space indent-style = space
@ -515,4 +543,29 @@ a = """\\\x1f"""
``` ```
#### Preview changes
```diff
--- Stable
+++ Preview
@@ -70,9 +70,9 @@
# String continuation
-"Let's" 'start' 'with' 'a' 'simple' 'example'
+"Let'sstartwithasimpleexample"
-"Let's" 'start' 'with' 'a' 'simple' 'example' 'now repeat after me:' 'I am confident' 'I am confident' 'I am confident' 'I am confident' 'I am confident'
+"Let'sstartwithasimpleexamplenow repeat after me:I am confidentI am confidentI am confidentI am confidentI am confident"
(
"Let's"
@@ -139,7 +139,7 @@
]
# Parenthesized string continuation with messed up indentation
-{'key': ([], 'a' 'b' 'c')}
+{'key': ([], 'abc')}
# Regression test for https://github.com/astral-sh/ruff/issues/5893
```

View file

@ -279,4 +279,37 @@ print((yield x))
``` ```
## Preview changes
```diff
--- Stable
+++ Preview
@@ -78,7 +78,7 @@
)
)
-yield "Cache key will cause errors if used with memcached: %r " "(longer than %s)" % (
+yield "Cache key will cause errors if used with memcached: %r (longer than %s)" % (
key,
MEMCACHE_MAX_KEY_LENGTH,
)
@@ -96,8 +96,7 @@
"Django to create, modify, and delete the table"
)
yield (
- "# Feel free to rename the models, but don't rename db_table values or "
- "field names."
+ "# Feel free to rename the models, but don't rename db_table values or field names."
)
yield (
@@ -109,8 +108,7 @@
"Django to create, modify, and delete the table"
)
yield (
- "# Feel free to rename the models, but don't rename db_table values or "
- "field names."
+ "# Feel free to rename the models, but don't rename db_table values or field names."
)
# Regression test for: https://github.com/astral-sh/ruff/issues/7420
```

View file

@ -188,6 +188,3 @@ f3 = { # f3
{ # f 4 { # f 4
} }
``` ```

View file

@ -831,7 +831,51 @@ match x:
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb, bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,
ccccccccccccccccccccccccccccccccc, ccccccccccccccccccccccccccccccccc,
): ):
@@ -246,63 +238,48 @@ @@ -220,89 +212,80 @@
## Always use parentheses for implicitly concatenated strings
match x:
- case (
- "implicit" "concatenated" "string"
- | [aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd]
- ):
+ case "implicitconcatenatedstring" | [
+ aaaaaa,
+ bbbbbbbbbbbbbbbb,
+ cccccccccccccccccc,
+ ddddddddddddddddddddddddddd,
+ ]:
pass
match x:
- case (
- b"implicit" b"concatenated" b"string"
- | [aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd]
- ):
+ case b"implicitconcatenatedstring" | [
+ aaaaaa,
+ bbbbbbbbbbbbbbbb,
+ cccccccccccccccccc,
+ ddddddddddddddddddddddddddd,
+ ]:
pass
match x:
- case (
- f"implicit" "concatenated" "string"
- | [aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd]
- ):
+ case f"implicitconcatenatedstring" | [
+ aaaaaa,
+ bbbbbbbbbbbbbbbb,
+ cccccccccccccccccc,
+ ddddddddddddddddddddddddddd,
+ ]:
pass
## Complex number expressions and unary expressions ## Complex number expressions and unary expressions
match x: match x:

View file

@ -131,6 +131,28 @@ def docstring_single():
``` ```
#### Preview changes
```diff
--- Stable
+++ Preview
@@ -33,10 +33,10 @@
rb"""br single triple"""
rb"""br double triple"""
-'single1' 'single2'
-'single1' 'double2'
-'double1' 'single2'
-'double1' 'double2'
+'single1single2'
+'single1double2'
+'double1single2'
+'double1double2'
def docstring_single_triple():
```
### Output 2 ### Output 2
``` ```
indent-style = space indent-style = space
@ -205,6 +227,28 @@ def docstring_single():
``` ```
#### Preview changes
```diff
--- Stable
+++ Preview
@@ -33,10 +33,10 @@
rb"""br single triple"""
rb"""br double triple"""
-"single1" "single2"
-"single1" "double2"
-"double1" "single2"
-"double1" "double2"
+"single1single2"
+"single1double2"
+"double1single2"
+"double1double2"
def docstring_single_triple():
```
### Output 3 ### Output 3
``` ```
indent-style = space indent-style = space
@ -279,4 +323,23 @@ def docstring_single():
``` ```
#### Preview changes
```diff
--- Stable
+++ Preview
@@ -33,10 +33,10 @@
rb'''br single triple'''
rb"""br double triple"""
-'single1' 'single2'
-'single1' "double2"
-"double1" 'single2'
-"double1" "double2"
+'single1single2'
+'single1double2'
+"double1single2"
+"double1double2"
def docstring_single_triple():
```

View file

@ -362,4 +362,154 @@ assert package.files == [
``` ```
## Preview changes
```diff
--- Stable
+++ Preview
@@ -30,50 +30,47 @@
def test():
- assert (
- {
- key1: value1,
- key2: value2,
- key3: value3,
- key4: value4,
- key5: value5,
- key6: value6,
- key7: value7,
- key8: value8,
- key9: value9,
- }
- == expected
- ), "Not what we expected and the message is too long to fit ineeeeee one line"
+ assert {
+ key1: value1,
+ key2: value2,
+ key3: value3,
+ key4: value4,
+ key5: value5,
+ key6: value6,
+ key7: value7,
+ key8: value8,
+ key9: value9,
+ } == expected, (
+ "Not what we expected and the message is too long to fit ineeeeee one line"
+ )
- assert (
- {
- key1: value1,
- key2: value2,
- key3: value3,
- key4: value4,
- key5: value5,
- key6: value6,
- key7: value7,
- key8: value8,
- key9: value9,
- }
- == expected
- ), "Not what we expected and the message is too long to fit in one lineeeee"
+ assert {
+ key1: value1,
+ key2: value2,
+ key3: value3,
+ key4: value4,
+ key5: value5,
+ key6: value6,
+ key7: value7,
+ key8: value8,
+ key9: value9,
+ } == expected, (
+ "Not what we expected and the message is too long to fit in one lineeeee"
+ )
- assert (
- {
- key1: value1,
- key2: value2,
- key3: value3,
- key4: value4,
- key5: value5,
- key6: value6,
- key7: value7,
- key8: value8,
- key9: value9,
- }
- == expected
- ), "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeee"
+ assert {
+ key1: value1,
+ key2: value2,
+ key3: value3,
+ key4: value4,
+ key5: value5,
+ key6: value6,
+ key7: value7,
+ key8: value8,
+ key9: value9,
+ } == expected, (
+ "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeee"
+ )
assert (
{
@@ -103,7 +100,9 @@
key9: value9,
}
== expectedeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
- ), "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeeeeeee"
+ ), (
+ "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeeeeeee"
+ )
assert expected == {
key1: value1,
@@ -117,20 +116,19 @@
key9: value9,
}, "Not what we expected and the message is too long to fit ineeeeee one line"
- assert (
- expected
- == {
- key1: value1,
- key2: value2,
- key3: value3,
- key4: value4,
- key5: value5,
- key6: value6,
- key7: value7,
- key8: value8,
- key9: value9,
- }
- ), "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeeeeeeeeee"
+ assert expected == {
+ key1: value1,
+ key2: value2,
+ key3: value3,
+ key4: value4,
+ key5: value5,
+ key6: value6,
+ key7: value7,
+ key8: value8,
+ key9: value9,
+ }, (
+ "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeeeeeeeeee"
+ )
assert (
expectedeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
@@ -160,7 +158,9 @@
key8: value8,
key9: value9,
}
- ), "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeeeee"
+ ), (
+ "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeeeee"
+ )
# Test for https://github.com/astral-sh/ruff/issues/7246
```

View file

@ -397,6 +397,21 @@ def f() -> (
pass pass
@@ -80,12 +78,12 @@
#########################################################################################
-def test_implicit_concatenated_string_return_type() -> "str" "bbbbbbbbbbbbbbbb":
+def test_implicit_concatenated_string_return_type() -> "strbbbbbbbbbbbbbbbb":
pass
def test_overlong_implicit_concatenated_string_return_type() -> (
- "liiiiiiiiiiiisssssst[str]" "bbbbbbbbbbbbbbbb"
+ "liiiiiiiiiiiisssssst[str]bbbbbbbbbbbbbbbb"
):
pass
@@ -108,9 +106,9 @@ @@ -108,9 +106,9 @@
# 1. Black tries to keep the list flat by parenthesizing the list as shown below even when the `list` identifier # 1. Black tries to keep the list flat by parenthesizing the list as shown below even when the `list` identifier
# fits on the header line. IMO, this adds unnecessary parentheses that can be avoided # fits on the header line. IMO, this adds unnecessary parentheses that can be avoided

View file

@ -412,3 +412,26 @@ def test_return_multiline_string_binary_expression_return_type_annotation(
]: ]:
pass pass
``` ```
## Preview changes
```diff
--- Stable
+++ Preview
@@ -82,13 +82,13 @@
#########################################################################################
-def test_implicit_concatenated_string_return_type(a) -> "str" "bbbbbbbbbbbbbbbb":
+def test_implicit_concatenated_string_return_type(a) -> "strbbbbbbbbbbbbbbbb":
pass
def test_overlong_implicit_concatenated_string_return_type(
a,
-) -> "liiiiiiiiiiiisssssst[str]" "bbbbbbbbbbbbbbbb":
+) -> "liiiiiiiiiiiisssssst[str]bbbbbbbbbbbbbbbb":
pass
```