diff --git a/crates/ruff_formatter/src/builders.rs b/crates/ruff_formatter/src/builders.rs index 33ea49724eb24..21ab988b5e38e 100644 --- a/crates/ruff_formatter/src/builders.rs +++ b/crates/ruff_formatter/src/builders.rs @@ -1454,7 +1454,7 @@ impl std::fmt::Debug for Group<'_, Context> { /// 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 -/// best performance. +/// better performance. /// /// ```rust /// # use ruff_formatter::prelude::*; diff --git a/crates/ruff_formatter/src/format_extensions.rs b/crates/ruff_formatter/src/format_extensions.rs index 33ca7b58e1daa..63ac361ee5799 100644 --- a/crates/ruff_formatter/src/format_extensions.rs +++ b/crates/ruff_formatter/src/format_extensions.rs @@ -139,7 +139,7 @@ where /// # Ok(()) /// # } /// ``` - pub fn inspect(&mut self, f: &mut Formatter) -> FormatResult<&[FormatElement]> { + pub fn inspect(&self, f: &mut Formatter) -> FormatResult<&[FormatElement]> { let result = self.memory.get_or_init(|| f.intern(&self.inner)); match result.as_ref() { diff --git a/crates/ruff_python_ast/src/expression.rs b/crates/ruff_python_ast/src/expression.rs index 6fb0ef9d29306..469f12b003af4 100644 --- a/crates/ruff_python_ast/src/expression.rs +++ b/crates/ruff_python_ast/src/expression.rs @@ -506,7 +506,7 @@ pub enum StringLikePart<'a> { FString(&'a ast::FString), } -impl StringLikePart<'_> { +impl<'a> StringLikePart<'a> { /// Returns the [`AnyStringFlags`] for the current string-like part. pub fn flags(&self) -> AnyStringFlags { 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 { matches!(self, Self::FString(_)) } @@ -571,6 +582,7 @@ impl Ranged for StringLikePart<'_> { /// An iterator over all the [`StringLikePart`] of a string-like expression. /// /// This is created by the [`StringLike::parts`] method. +#[derive(Clone)] pub enum StringLikePartIter<'a> { String(std::slice::Iter<'a, ast::StringLiteral>), 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 { + 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 ExactSizeIterator for StringLikePartIter<'_> {} diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string.options.json new file mode 100644 index 0000000000000..8925dd0a8280f --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string.options.json @@ -0,0 +1,5 @@ +[ + { + "preview": "enabled" + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string.py new file mode 100644 index 0000000000000..6dccb377d898c --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string.py @@ -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" +) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_assignment.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_assignment.options.json new file mode 100644 index 0000000000000..8925dd0a8280f --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_assignment.options.json @@ -0,0 +1,5 @@ +[ + { + "preview": "enabled" + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_assignment.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_assignment.py new file mode 100644 index 0000000000000..2cd23efdc254d --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_assignment.py @@ -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 +) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.options.json new file mode 100644 index 0000000000000..b7d2d58057d95 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.options.json @@ -0,0 +1,11 @@ +[ + { + "quote_style": "preserve", + "preview": "enabled" + }, + { + "quote_style": "preserve", + "preview": "enabled", + "target_version": "py312" + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.py new file mode 100644 index 0000000000000..2492be47039e7 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.py @@ -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'"}' diff --git a/crates/ruff_python_formatter/src/comments/mod.rs b/crates/ruff_python_formatter/src/comments/mod.rs index 34808e7867a73..c2db89159bafa 100644 --- a/crates/ruff_python_formatter/src/comments/mod.rs +++ b/crates/ruff_python_formatter/src/comments/mod.rs @@ -431,6 +431,41 @@ impl<'a> Comments<'a> { pub(crate) fn debug(&'a self, source_code: SourceCode<'a>) -> DebugComments<'a> { 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>; diff --git a/crates/ruff_python_formatter/src/context.rs b/crates/ruff_python_formatter/src/context.rs index 32169ccf7dc92..b449b95eca692 100644 --- a/crates/ruff_python_formatter/src/context.rs +++ b/crates/ruff_python_formatter/src/context.rs @@ -8,7 +8,6 @@ use ruff_source_file::Locator; use std::fmt::{Debug, Formatter}; use std::ops::{Deref, DerefMut}; -#[derive(Clone)] pub struct PyFormatContext<'a> { options: PyFormatOptions, contents: &'a str, @@ -52,7 +51,6 @@ impl<'a> PyFormatContext<'a> { self.contents } - #[allow(unused)] pub(crate) fn locator(&self) -> Locator<'a> { Locator::new(self.contents) } diff --git a/crates/ruff_python_formatter/src/expression/binary_like.rs b/crates/ruff_python_formatter/src/expression/binary_like.rs index 46b5e15bc5e2f..be6f3ec22d212 100644 --- a/crates/ruff_python_formatter/src/expression/binary_like.rs +++ b/crates/ruff_python_formatter/src/expression/binary_like.rs @@ -20,7 +20,7 @@ use crate::expression::parentheses::{ }; use crate::expression::OperatorPrecedence; use crate::prelude::*; -use crate::string::FormatImplicitConcatenatedString; +use crate::string::implicit::FormatImplicitConcatenatedString; #[derive(Copy, Clone, Debug)] pub(super) enum BinaryLike<'a> { diff --git a/crates/ruff_python_formatter/src/expression/expr_bytes_literal.rs b/crates/ruff_python_formatter/src/expression/expr_bytes_literal.rs index 7b6837b655080..92d2af2d1f952 100644 --- a/crates/ruff_python_formatter/src/expression/expr_bytes_literal.rs +++ b/crates/ruff_python_formatter/src/expression/expr_bytes_literal.rs @@ -5,7 +5,8 @@ use crate::expression::parentheses::{ in_parentheses_only_group, NeedsParentheses, OptionalParentheses, }; use crate::prelude::*; -use crate::string::{FormatImplicitConcatenatedString, StringLikeExtensions}; +use crate::string::implicit::FormatImplicitConcatenatedStringFlat; +use crate::string::{implicit::FormatImplicitConcatenatedString, StringLikeExtensions}; #[derive(Default)] pub struct FormatExprBytesLiteral; @@ -14,9 +15,19 @@ impl FormatNodeRule for FormatExprBytesLiteral { fn fmt_fields(&self, item: &ExprBytesLiteral, f: &mut PyFormatter) -> FormatResult<()> { let ExprBytesLiteral { value, .. } = item; - match value.as_slice() { - [bytes_literal] => bytes_literal.format().fmt(f), - _ => in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f), + if let [bytes_literal] = value.as_slice() { + bytes_literal.format().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) } } } diff --git a/crates/ruff_python_formatter/src/expression/expr_f_string.rs b/crates/ruff_python_formatter/src/expression/expr_f_string.rs index e88638d7c26fe..a4b0325d7976a 100644 --- a/crates/ruff_python_formatter/src/expression/expr_f_string.rs +++ b/crates/ruff_python_formatter/src/expression/expr_f_string.rs @@ -7,7 +7,8 @@ use crate::expression::parentheses::{ }; use crate::other::f_string_part::FormatFStringPart; use crate::prelude::*; -use crate::string::{FormatImplicitConcatenatedString, Quoting, StringLikeExtensions}; +use crate::string::implicit::FormatImplicitConcatenatedStringFlat; +use crate::string::{implicit::FormatImplicitConcatenatedString, Quoting, StringLikeExtensions}; #[derive(Default)] pub struct FormatExprFString; @@ -16,13 +17,23 @@ impl FormatNodeRule for FormatExprFString { fn fmt_fields(&self, item: &ExprFString, f: &mut PyFormatter) -> FormatResult<()> { let ExprFString { value, .. } = item; - match value.as_slice() { - [f_string_part] => FormatFStringPart::new( + if let [f_string_part] = value.as_slice() { + FormatFStringPart::new( f_string_part, f_string_quoting(item, &f.context().locator()), ) - .fmt(f), - _ => in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f), + .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 { if self.value.is_implicit_concatenated() { OptionalParentheses::Multiline + } // TODO(dhruvmanila): Ideally what we want here is a new variant which // is something like: // - 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: // 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 } else { OptionalParentheses::BestFit diff --git a/crates/ruff_python_formatter/src/expression/expr_string_literal.rs b/crates/ruff_python_formatter/src/expression/expr_string_literal.rs index 2ee661f76db60..9813958de52cf 100644 --- a/crates/ruff_python_formatter/src/expression/expr_string_literal.rs +++ b/crates/ruff_python_formatter/src/expression/expr_string_literal.rs @@ -6,7 +6,8 @@ use crate::expression::parentheses::{ }; use crate::other::string_literal::StringLiteralKind; use crate::prelude::*; -use crate::string::{FormatImplicitConcatenatedString, StringLikeExtensions}; +use crate::string::implicit::FormatImplicitConcatenatedStringFlat; +use crate::string::{implicit::FormatImplicitConcatenatedString, StringLikeExtensions}; #[derive(Default)] pub struct FormatExprStringLiteral { @@ -26,16 +27,20 @@ impl FormatNodeRule for FormatExprStringLiteral { fn fmt_fields(&self, item: &ExprStringLiteral, f: &mut PyFormatter) -> FormatResult<()> { let ExprStringLiteral { value, .. } = item; - match value.as_slice() { - [string_literal] => string_literal.format().with_options(self.kind).fmt(f), - _ => { - // This is just a sanity check because [`DocstringStmt::try_from_statement`] - // ensures that the docstring is a *single* string literal. - assert!(!self.kind.is_docstring()); - - in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)) + if let [string_literal] = value.as_slice() { + string_literal.format().with_options(self.kind).fmt(f) + } else { + // Always join strings that aren't parenthesized and thus, always on a single line. + if !f.context().node_level().is_parenthesized() { + if let Some(mut format_flat) = + FormatImplicitConcatenatedStringFlat::new(item.into(), f.context()) + { + format_flat.set_docstring(self.kind.is_docstring()); + return format_flat.fmt(f); + } } - .fmt(f), + + in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f) } } } diff --git a/crates/ruff_python_formatter/src/expression/mod.rs b/crates/ruff_python_formatter/src/expression/mod.rs index 1cc060ec11423..d270f910e89df 100644 --- a/crates/ruff_python_formatter/src/expression/mod.rs +++ b/crates/ruff_python_formatter/src/expression/mod.rs @@ -21,6 +21,7 @@ use crate::expression::parentheses::{ use crate::prelude::*; use crate::preview::{ 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, }; @@ -405,38 +406,39 @@ impl Format> for MaybeParenthesizeExpression<'_> { needs_parentheses => needs_parentheses, }; + let unparenthesized = expression.format().with_options(Parentheses::Never); + match needs_parentheses { OptionalParentheses::Multiline => match parenthesize { - 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)) - .fmt(f) - } - Parenthesize::IfRequired => { - expression.format().with_options(Parentheses::Never).fmt(f) + parenthesize_if_expands(&unparenthesized).fmt(f) } + Parenthesize::IfRequired => unparenthesized.fmt(f), + Parenthesize::Optional | Parenthesize::IfBreaks | Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested => { if can_omit_optional_parentheses(expression, f.context()) { - optional_parentheses(&expression.format().with_options(Parentheses::Never)) - .fmt(f) + optional_parentheses(&unparenthesized).fmt(f) } else { - parenthesize_if_expands( - &expression.format().with_options(Parentheses::Never), - ) - .fmt(f) + parenthesize_if_expands(&unparenthesized).fmt(f) } } }, 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_if_expands(&expression.format().with_options(Parentheses::Never)) - .fmt(f) + // Can-omit layout is relevant for `"abcd".call`. We don't want to add unnecessary + // 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 => { - expression.format().with_options(Parentheses::Never).fmt(f) - } + Parenthesize::Optional | Parenthesize::IfRequired => unparenthesized.fmt(f), Parenthesize::IfBreaks => { if node_comments.has_trailing() { @@ -446,7 +448,7 @@ impl Format> for MaybeParenthesizeExpression<'_> { let group_id = f.group_id("optional_parentheses"); 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)) .fmt(f) } @@ -454,13 +456,13 @@ impl Format> for MaybeParenthesizeExpression<'_> { }, OptionalParentheses::Never => match parenthesize { 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())) .fmt(f) } 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, .. }) if value.is_implicit_concatenated() => { - self.update_max_precedence(OperatorPrecedence::String); + if !is_f_string_implicit_concatenated_string_literal_quotes_enabled(self.context) { + self.update_max_precedence(OperatorPrecedence::String); + } + + return; } Expr::BytesLiteral(ast::ExprBytesLiteral { value, .. }) if value.is_implicit_concatenated() => { - self.update_max_precedence(OperatorPrecedence::String); + if !is_f_string_implicit_concatenated_string_literal_quotes_enabled(self.context) { + self.update_max_precedence(OperatorPrecedence::String); + } + + return; } Expr::FString(ast::ExprFString { value, .. }) if value.is_implicit_concatenated() => { - self.update_max_precedence(OperatorPrecedence::String); + if !is_f_string_implicit_concatenated_string_literal_quotes_enabled(self.context) { + self.update_max_precedence(OperatorPrecedence::String); + } + return; } diff --git a/crates/ruff_python_formatter/src/other/f_string.rs b/crates/ruff_python_formatter/src/other/f_string.rs index 826d5dbf67caf..97e31bdd1e0bc 100644 --- a/crates/ruff_python_formatter/src/other/f_string.rs +++ b/crates/ruff_python_formatter/src/other/f_string.rs @@ -76,14 +76,9 @@ impl Format> for FormatFString<'_> { let quotes = StringQuotes::from(string_kind); write!(f, [string_kind.prefix(), quotes])?; - f.join() - .entries( - self.value - .elements - .iter() - .map(|element| FormatFStringElement::new(element, context)), - ) - .finish()?; + for element in &self.value.elements { + FormatFStringElement::new(element, context).fmt(f)?; + } // Ending quote quotes.fmt(f) @@ -98,7 +93,7 @@ pub(crate) struct FStringContext { } impl FStringContext { - const fn new(flags: AnyStringFlags, layout: FStringLayout) -> Self { + pub(crate) const fn new(flags: AnyStringFlags, layout: FStringLayout) -> Self { Self { enclosing_flags: flags, layout, @@ -125,7 +120,7 @@ pub(crate) enum 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 // 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 diff --git a/crates/ruff_python_formatter/src/other/f_string_element.rs b/crates/ruff_python_formatter/src/other/f_string_element.rs index b47a0fa220084..77daaca0d25a4 100644 --- a/crates/ruff_python_formatter/src/other/f_string_element.rs +++ b/crates/ruff_python_formatter/src/other/f_string_element.rs @@ -61,7 +61,8 @@ impl<'a> FormatFStringLiteralElement<'a> { impl Format> for FormatFStringLiteralElement<'_> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { 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 { Cow::Borrowed(_) => source_text_slice(self.element.range()).fmt(f), Cow::Owned(normalized) => text(normalized).fmt(f), @@ -235,11 +236,9 @@ impl Format> for FormatFStringExpressionElement<'_> { if let Some(format_spec) = format_spec.as_deref() { token(":").fmt(f)?; - f.join() - .entries(format_spec.elements.iter().map(|element| { - FormatFStringElement::new(element, self.context.f_string()) - })) - .finish()?; + for element in &format_spec.elements { + FormatFStringElement::new(element, self.context.f_string()).fmt(f)?; + } // These trailing comments can only occur if the format specifier is // present. For example, diff --git a/crates/ruff_python_formatter/src/pattern/mod.rs b/crates/ruff_python_formatter/src/pattern/mod.rs index d564a6f97025a..d2ba4801dd26e 100644 --- a/crates/ruff_python_formatter/src/pattern/mod.rs +++ b/crates/ruff_python_formatter/src/pattern/mod.rs @@ -14,6 +14,7 @@ use crate::expression::parentheses::{ optional_parentheses, parenthesized, NeedsParentheses, OptionalParentheses, Parentheses, }; use crate::prelude::*; +use crate::preview::is_join_implicit_concatenated_string_enabled; pub(crate) mod pattern_arguments; pub(crate) mod pattern_keyword; @@ -226,7 +227,7 @@ pub(crate) fn can_pattern_omit_optional_parentheses( pattern: &Pattern, context: &PyFormatContext, ) -> bool { - let mut visitor = CanOmitOptionalParenthesesVisitor::default(); + let mut visitor = CanOmitOptionalParenthesesVisitor::new(context); visitor.visit_pattern(pattern, context); 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> { max_precedence: OperatorPrecedence, max_precedence_count: usize, any_parenthesized_expressions: bool, + join_implicit_concatenated_strings: bool, last: Option<&'input Pattern>, first: First<'input>, } 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) { match pattern { Pattern::MatchSequence(_) | Pattern::MatchMapping(_) => { @@ -289,18 +306,24 @@ impl<'a> CanOmitOptionalParenthesesVisitor<'a> { Pattern::MatchValue(value) => match &*value.value { Expr::StringLiteral(string) => { - self.update_max_precedence(OperatorPrecedence::String, string.value.len()); + if !self.join_implicit_concatenated_strings { + self.update_max_precedence(OperatorPrecedence::String, string.value.len()); + } } Expr::BytesLiteral(bytes) => { - self.update_max_precedence(OperatorPrecedence::String, bytes.value.len()); + if !self.join_implicit_concatenated_strings { + 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. // That's why we need to support them for formatting. Expr::FString(string) => { - self.update_max_precedence( - OperatorPrecedence::String, - string.value.as_slice().len(), - ); + if !self.join_implicit_concatenated_strings { + self.update_max_precedence( + OperatorPrecedence::String, + string.value.as_slice().len(), + ); + } } Expr::NumberLiteral(_) | Expr::Attribute(_) | Expr::UnaryOp(_) => { diff --git a/crates/ruff_python_formatter/src/preview.rs b/crates/ruff_python_formatter/src/preview.rs index bacb9d203f02a..8ac7da66c6499 100644 --- a/crates/ruff_python_formatter/src/preview.rs +++ b/crates/ruff_python_formatter/src/preview.rs @@ -62,3 +62,11 @@ pub(crate) fn is_docstring_code_block_in_docstring_indent_enabled( ) -> bool { 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() +} diff --git a/crates/ruff_python_formatter/src/range.rs b/crates/ruff_python_formatter/src/range.rs index 3541f46e4cf08..33f7d2a1d2fe7 100644 --- a/crates/ruff_python_formatter/src/range.rs +++ b/crates/ruff_python_formatter/src/range.rs @@ -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 // docstrings and docstring formatting won't kick in. // 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| { - DocstringStmt::is_docstring_statement(stmt, self.context.options().source_type()) - }); + let is_maybe_docstring = node + .as_stmt_expr() + .is_some_and(|stmt| DocstringStmt::is_docstring_statement(stmt, self.context)); if is_maybe_docstring { return TraversalSignal::Skip; diff --git a/crates/ruff_python_formatter/src/statement/stmt_assert.rs b/crates/ruff_python_formatter/src/statement/stmt_assert.rs index 91a85fe04925b..2e7e99240beba 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_assert.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_assert.rs @@ -6,6 +6,7 @@ use crate::comments::SourceComment; use crate::expression::maybe_parenthesize_expression; use crate::expression::parentheses::Parenthesize; +use crate::preview::is_join_implicit_concatenated_string_enabled; use crate::{has_skip_comment, prelude::*}; #[derive(Default)] @@ -29,12 +30,18 @@ impl FormatNodeRule for FormatStmtAssert { )?; if let Some(msg) = msg { + let parenthesize = if is_join_implicit_concatenated_string_enabled(f.context()) { + Parenthesize::IfBreaksParenthesized + } else { + Parenthesize::IfBreaks + }; + write!( f, [ token(","), space(), - maybe_parenthesize_expression(msg, item, Parenthesize::IfBreaks), + maybe_parenthesize_expression(msg, item, parenthesize), ] )?; } diff --git a/crates/ruff_python_formatter/src/statement/stmt_assign.rs b/crates/ruff_python_formatter/src/statement/stmt_assign.rs index 3e4da62aedd84..f568d2a9756cb 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_assign.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_assign.rs @@ -1,6 +1,6 @@ -use ruff_formatter::{format_args, write, FormatError}; +use ruff_formatter::{format_args, write, FormatError, RemoveSoftLinesBuffer}; 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; @@ -16,7 +16,11 @@ use crate::expression::{ can_omit_optional_parentheses, has_own_parentheses, has_parentheses, maybe_parenthesize_expression, }; +use crate::preview::is_join_implicit_concatenated_string_enabled; use crate::statement::trailing_semicolon; +use crate::string::implicit::{ + FormatImplicitConcatenatedStringExpanded, FormatImplicitConcatenatedStringFlat, +}; use crate::{has_skip_comment, prelude::*}; #[derive(Default)] @@ -281,8 +285,11 @@ impl Format> for FormatStatementsLastExpression<'_> { match self { FormatStatementsLastExpression::LeftToRight { value, statement } => { 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( value, *statement, @@ -301,28 +308,149 @@ impl Format> for FormatStatementsLastExpression<'_> { ) { let group_id = f.group_id("optional_parentheses"); - let f = &mut WithNodeLevel::new(NodeLevel::Expression(Some(group_id)), f); - - best_fit_parenthesize(&format_with(|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(); - - value.format().with_options(Parentheses::Never).fmt(f)?; - - if !inline_comments.is_empty() { - // If the expressions exceeds the line width, format the comments in the parentheses - if_group_breaks(&inline_comments).fmt(f)?; + let string = flat.string(); + + 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, + )] + ); } - Ok(()) - })) - .with_group_id(Some(group_id)) - .fmt(f)?; - - if !inline_comments.is_empty() { - // If the line fits into the line width, format the comments after the parenthesized expression - if_group_fits_on_line(&inline_comments) + 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(); + + value.format().with_options(Parentheses::Never).fmt(f)?; + + if !inline_comments.is_empty() { + // If the expressions exceeds the line width, format the comments in the parentheses + if_group_breaks(&inline_comments).fmt(f)?; + } + + Ok(()) + })) + .with_group_id(Some(group_id)) + .fmt(f)?; + + if !inline_comments.is_empty() { + // If the line fits into the line width, format the comments after the parenthesized expression + if_group_fits_on_line(&inline_comments) + .with_group_id(Some(group_id)) + .fmt(f)?; + } } Ok(()) @@ -339,10 +467,14 @@ impl Format> for FormatStatementsLastExpression<'_> { statement, } => { 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. if !should_inline_comments && !should_non_inlineable_use_best_fit(value, *statement, f.context()) + && format_implicit_flat.is_none() { return write!( f, @@ -364,7 +496,7 @@ impl Format> for FormatStatementsLastExpression<'_> { let expression_comments = comments.leading_dangling_trailing(*value); // 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( &expression_comments, *statement, @@ -396,13 +528,14 @@ impl Format> for FormatStatementsLastExpression<'_> { // Prevent inline comments to be formatted as part of the expression. 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. // 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 // 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!( f, [ @@ -416,13 +549,29 @@ impl Format> 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: // ```python // a = b = c // ``` - let format_flat = format_with(|f| { + let single_line = format_with(|f| { write!( f, [ @@ -443,19 +592,21 @@ impl Format> for FormatStatementsLastExpression<'_> { // c // ) // ``` - let format_parenthesize_value = format_with(|f| { - write!( - f, - [ - last_target, - space(), - operator, - space(), - token("("), - block_indent(&format_args![format_value, inline_comments]), - token(")") - ] - ) + let flat_target_parenthesize_value = format_with(|f| { + write!(f, [last_target, space(), operator, space(), token("("),])?; + + if is_join_implicit_concatenated_string_enabled(f.context()) { + group(&soft_block_indent(&format_args![ + format_value, + inline_comments + ])) + .should_expand(true) + .fmt(f)?; + } 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 @@ -466,17 +617,16 @@ impl Format> for FormatStatementsLastExpression<'_> { // "bbbbb" // ] = 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!( f, - [ - last_target, - space(), - operator, - space(), - format_value, - inline_comments - ] + [space(), operator, space(), format_value, inline_comments] ) }); @@ -486,7 +636,7 @@ impl Format> for FormatStatementsLastExpression<'_> { // 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() { best_fitting![ - format_flat, + single_line, // Avoid parenthesizing the call expression if the `(` fit on the line format_args![ last_target, @@ -495,12 +645,165 @@ impl Format> for FormatStatementsLastExpression<'_> { space(), group(&format_value).should_expand(true), ], - format_parenthesize_value, - format_split_left + flat_target_parenthesize_value, + split_target_flat_value ] .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 { + 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![format_flat, format_parenthesize_value, format_split_left].fmt(f) + 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(); } } + + fn mark_unformatted(&self) { + for comment in self.expression { + comment.mark_unformatted(); + } + } } impl Format> for OptionalParenthesesInlinedComments<'_> { diff --git a/crates/ruff_python_formatter/src/statement/suite.rs b/crates/ruff_python_formatter/src/statement/suite.rs index c483f917e2395..7b275677ef288 100644 --- a/crates/ruff_python_formatter/src/statement/suite.rs +++ b/crates/ruff_python_formatter/src/statement/suite.rs @@ -138,7 +138,7 @@ impl FormatRule> for FormatSuite { SuiteKind::Function | SuiteKind::Class | SuiteKind::TopLevel => { 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) } else { @@ -179,7 +179,7 @@ impl FormatRule> for FormatSuite { // Insert a newline after a module level docstring, but treat // it as a docstring otherwise. See: https://github.com/psf/black/pull/3932. 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() }; @@ -785,37 +785,23 @@ impl<'a> DocstringStmt<'a> { fn try_from_statement( stmt: &'a Stmt, suite_kind: SuiteKind, - source_type: PySourceType, + context: &PyFormatContext, ) -> Option> { // 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; } - let Stmt::Expr(ast::StmtExpr { value, .. }) = stmt else { - return None; - }; - - match value.as_ref() { - Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) - if !value.is_implicit_concatenated() => - { - Some(DocstringStmt { - docstring: stmt, - suite_kind, - }) - } - _ => None, - } + Self::is_docstring_statement(stmt.as_expr_stmt()?, context).then_some(DocstringStmt { + docstring: stmt, + suite_kind, + }) } - 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() { !value.is_implicit_concatenated() + || !value.iter().any(|literal| context.comments().has(literal)) } else { false } diff --git a/crates/ruff_python_formatter/src/string/docstring.rs b/crates/ruff_python_formatter/src/string/docstring.rs index bb1afeee76454..3279d40106438 100644 --- a/crates/ruff_python_formatter/src/string/docstring.rs +++ b/crates/ruff_python_formatter/src/string/docstring.rs @@ -9,7 +9,7 @@ use std::{borrow::Cow, collections::VecDeque}; use regex::Regex; 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_formatter::{write, FormatOptions, IndentStyle, LineWidth, Printed}, @@ -19,7 +19,10 @@ use { }; 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::{prelude::*, DocstringCodeLineWidth, FormatModuleError}; @@ -167,7 +170,7 @@ pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form if docstring[first.len()..].trim().is_empty() { // For `"""\n"""` or other whitespace between the quotes, black keeps a single whitespace, // 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()) { space().fmt(f)?; @@ -207,7 +210,7 @@ pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form let trim_end = docstring .as_ref() .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)?; } @@ -1604,9 +1607,18 @@ fn docstring_format_source( /// 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, /// so `content\\ """` doesn't need a space while `content\\\ """` does. -fn needs_chaperone_space(normalized: &NormalizedString, trim_end: &str) -> bool { - trim_end.ends_with(normalized.flags().quote_style().as_char()) - || trim_end.chars().rev().take_while(|c| *c == '\\').count() % 2 == 1 +pub(super) fn needs_chaperone_space( + flags: AnyStringFlags, + 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)] diff --git a/crates/ruff_python_formatter/src/string/implicit.rs b/crates/ruff_python_formatter/src/string/implicit.rs new file mode 100644 index 0000000000000..18bfc728bd600 --- /dev/null +++ b/crates/ruff_python_formatter/src/string/implicit.rs @@ -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>) -> Self { + Self { + string: string.into(), + } + } +} + +impl Format> 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> for FormatImplicitConcatenatedStringExpanded<'_> { + fn fmt(&self, f: &mut Formatter>) -> 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 { + fn merge_flags(string: StringLike, context: &PyFormatContext) -> Option { + 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 = 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 = 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> for FormatImplicitConcatenatedStringFlat<'_> { + fn fmt(&self, f: &mut Formatter>) -> 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> 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(()) + } +} diff --git a/crates/ruff_python_formatter/src/string/mod.rs b/crates/ruff_python_formatter/src/string/mod.rs index 3eaf87121f459..dba9adc555bee 100644 --- a/crates/ruff_python_formatter/src/string/mod.rs +++ b/crates/ruff_python_formatter/src/string/mod.rs @@ -1,25 +1,21 @@ use memchr::memchr2; pub(crate) use normalize::{normalize_string, NormalizedString, StringNormalizer}; -use ruff_formatter::format_args; use ruff_python_ast::str::Quote; use ruff_python_ast::{ self as ast, str_prefix::{AnyStringPrefix, StringLiteralPrefix}, - AnyStringFlags, StringFlags, StringLike, StringLikePart, + AnyStringFlags, StringFlags, }; use ruff_source_file::Locator; use ruff_text_size::Ranged; -use crate::comments::{leading_comments, trailing_comments}; 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::QuoteStyle; pub(crate) mod docstring; +pub(crate) mod implicit; mod normalize; #[derive(Copy, Clone, Debug, Default)] @@ -29,57 +25,6 @@ pub(crate) enum Quoting { 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>) -> Self { - Self { - string: string.into(), - } - } -} - -impl Format> 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> for AnyStringPrefix { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { // 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 { match self { - Self::String(_) | Self::Bytes(_) => { - self.parts() - .next() - .is_some_and(|part| part.flags().is_triple_quoted()) + Self::String(_) | Self::Bytes(_) => self.parts().any(|part| { + part.flags().is_triple_quoted() && memchr2(b'\n', b'\r', source[self.range()].as_bytes()).is_some() - } + }), Self::FString(fstring) => { memchr2(b'\n', b'\r', source[fstring.range].as_bytes()).is_some() } diff --git a/crates/ruff_python_formatter/src/string/normalize.rs b/crates/ruff_python_formatter/src/string/normalize.rs index c4913091464f3..b1470fbf54ff3 100644 --- a/crates/ruff_python_formatter/src/string/normalize.rs +++ b/crates/ruff_python_formatter/src/string/normalize.rs @@ -44,6 +44,9 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { /// The formatter should use the preferred quote style unless /// it can't because the string contains the preferred quotes OR /// 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 { match self.quoting { Quoting::Preserve => QuoteStyle::Preserve, @@ -205,6 +208,8 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { quote_selection.flags, // TODO: Remove the `b'{'` in `choose_quotes` when promoting the // `format_fstring` preview style + false, + false, is_f_string_formatting_enabled(self.context), ) } else { @@ -598,6 +603,8 @@ pub(crate) fn normalize_string( input: &str, start_offset: usize, new_flags: AnyStringFlags, + escape_braces: bool, + flip_nested_fstring_quotes: bool, format_f_string: bool, ) -> Cow { // The normalized string if `input` is not yet normalized. @@ -620,16 +627,24 @@ pub(crate) fn normalize_string( while let Some((index, c)) = chars.next() { if matches!(c, '{' | '}') && is_fstring { - if chars.peek().copied().is_some_and(|(_, next)| next == c) { - // Skip over the second character of the double braces - chars.next(); - } else if c == '{' { - formatted_value_nesting += 1; - } else { - // Safe to assume that `c == '}'` here because of the matched pattern above - formatted_value_nesting = formatted_value_nesting.saturating_sub(1); + 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) { + // Skip over the second character of the double braces + chars.next(); + } else if c == '{' { + formatted_value_nesting += 1; + } else { + // Safe to assume that `c == '}'` here because of the matched pattern above + formatted_value_nesting = formatted_value_nesting.saturating_sub(1); + } + continue; } - continue; } if c == '\r' { @@ -697,6 +712,14 @@ pub(crate) fn normalize_string( output.push('\\'); output.push(c); 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 { use std::borrow::Cow; + use ruff_python_ast::str_prefix::FStringPrefix; use ruff_python_ast::{ str::Quote, str_prefix::{AnyStringPrefix, ByteStringPrefix}, @@ -1013,9 +1037,35 @@ mod tests { Quote::Double, false, ), + false, + false, true, ); 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 + ); + } } diff --git a/crates/ruff_python_formatter/tests/normalizer.rs b/crates/ruff_python_formatter/tests/normalizer.rs index e5c84c5ab799e..b83a8cee0c35b 100644 --- a/crates/ruff_python_formatter/tests/normalizer.rs +++ b/crates/ruff_python_formatter/tests/normalizer.rs @@ -6,7 +6,11 @@ use { use ruff_python_ast::visitor::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 /// semantic equivalence. @@ -59,6 +63,135 @@ impl Transformer for Normalizer { 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, + } + + 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) { static STRIP_DOC_TESTS: LazyLock = LazyLock::new(|| { Regex::new( diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__composition_no_trailing_comma.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__composition_no_trailing_comma.py.snap index 4888f9a617d8c..6a9570acf64ef 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__composition_no_trailing_comma.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__composition_no_trailing_comma.py.snap @@ -610,5 +610,3 @@ class C: } ) ``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap index a4c9bc8300142..fa0173187df9c 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap @@ -667,21 +667,21 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share "Arg #2", "Arg #3", "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.""" --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" - " 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." ++ "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" - " 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( -+ "formatting" ++ "Followed by a really really really long string that is used to provide context to the AssertionError exception, which uses dynamic string {}.".format( ++ "formatting" ++ ) ) assert some_type_of_boolean_expression, ( @@ -772,7 +772,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share x, y, z, -@@ -397,7 +306,7 @@ +@@ -397,7 +310,7 @@ func_with_bad_parens( x, y, @@ -781,7 +781,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share z, ) -@@ -408,50 +317,27 @@ +@@ -408,50 +321,27 @@ + CONCATENATED + "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 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.")) 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 = ( "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." ) @@ -902,7 +901,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share ) dict_with_lambda_values = { -@@ -524,65 +383,58 @@ +@@ -524,65 +387,58 @@ # Complex string concatenations with a method call in the middle. code = ( @@ -986,7 +985,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share ) log.info( -@@ -590,5 +442,5 @@ +@@ -590,5 +446,5 @@ ) 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.""" -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( - "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( + "formatting" + ) ) 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 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." diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap index e017e31abfe26..fc3857c2b45b2 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap @@ -614,26 +614,20 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: ), varX, varY, -@@ -70,9 +69,10 @@ - def foo(xxxx): +@@ -71,8 +70,9 @@ for xxx_xxxx, _xxx_xxx, _xxx_xxxxx, xxx_xxxx in 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}" - .format(xxx_xxxx, xxx, xxxxxx.xxxxxxx(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}".format( -+ xxx_xxxx, xxx, xxxxxx.xxxxxxx(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) ++ ) ) -@@ -80,10 +80,11 @@ - def disappearing_comment(): - return ( - ( # xx -x xxxxxxx xx xxx xxxxxxx. -- "{{xxx_xxxxxxxxxx_xxxxxxxx}} xxx xxxx {} {{xxxx}} >&2".format( -+ "{{xxx_xxxxxxxxxx_xxxxxxxx}} xxx xxxx" " {} {{xxxx}} >&2".format( +@@ -83,7 +83,8 @@ + "{{xxx_xxxxxxxxxx_xxxxxxxx}} xxx xxxx {} {{xxxx}} >&2".format( "{xxxx} {xxxxxx}" if xxxxx.xx_xxxxxxxxxx - 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" + % (len(self) + 1, xxxx.xxxxxxxxxx, xxxx.xxxxxxxxxx) -+ ) + ) + + ( + " %.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, + 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 = { "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. --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. - "This string is long but not so long that it needs hahahah toooooo be so greatttt" -@@ -279,37 +280,26 @@ + some_tuple = ("some string", "some string which should be joined") +@@ -279,34 +280,21 @@ ) 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}:/'" -) +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 = ( - "sudo -E deluge-console info --detailed --sort-reverse=time_added" - 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 = ( - "sudo -E deluge-console info --detailed --sort-reverse=time_added {'' if ID is" - 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" - 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: - class B: -@@ -364,10 +354,7 @@ +@@ -364,10 +352,7 @@ def foo(): if not hasattr(module, name): 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()) ) -@@ -382,23 +369,19 @@ +@@ -382,23 +367,19 @@ class Step(StepBase): 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. "(x.bbbbbbbbbbbb.xxx != " '"xxx:xxx:xxx::cccccccccccc:xxxxxxx-xxxx/xxxxxxxxxxx/xxxxxxxxxxxxxxxxx") && ' -@@ -409,8 +392,8 @@ +@@ -409,8 +390,8 @@ if __name__ == "__main__": for i in range(4, 8): 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 [ x.xxxxx.xxxxxx.xxxxx.xxxxxx, 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] = ( -@@ -449,8 +430,7 @@ +@@ -449,8 +428,7 @@ RE_TWO_BACKSLASHES = { "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. print( - "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam." - 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 = ( - "This is a long string which contains an f-expr that should not split" - 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)]}}}." # 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. ( @@ -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. -@@ -513,93 +489,83 @@ +@@ -513,93 +487,83 @@ 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." '7. At the top click the link for "API key".' "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. 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"<<{author.display_name}>>\n" ) - --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}." -+) +@@ -617,51 +581,34 @@ 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. -@@ -672,9 +623,11 @@ - } - +@@ -674,7 +621,4 @@ # 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 = ( + s = f"With single quote: ' {my_dict['foo']} With double quote: \" {my_dict['bar']}" + +-s = ( - "Lorem Ipsum is simply dummy text of the printing and typesetting" - 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']}'" ``` @@ -1281,10 +1255,10 @@ class A: def foo(xxxx): for xxx_xxxx, _xxx_xxx, _xxx_xxxxx, xxx_xxxx in xxxx: for xxx in xxx_xxxx: - 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}".format( - xxx_xxxx, xxx, xxxxxx.xxxxxxx(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}".format( + xxx_xxxx, xxx, xxxxxx.xxxxxxx(xxx_xxx_xxxxx) + ) ) @@ -1292,7 +1266,7 @@ class A: def disappearing_comment(): 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}" if xxxxx.xx_xxxxxxxxxx # Disappearing Comment @@ -1477,7 +1451,7 @@ def foo(): ) # 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. "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"We have to remember to escape {braces}." " Like {these}." f" But not {this}." -) +fstring = f"We have to remember to escape {braces}. Like {{these}}. But not {this}." class A: @@ -1791,9 +1763,7 @@ temp_msg = ( f"<<{author.display_name}>>\n" ) -fstring = ( - f"We have to remember to escape {braces}." " Like {these}." f" But not {this}." -) +fstring = f"We have to remember to escape {braces}. Like {{these}}. But not {this}." welcome_to_programming = R"hello," R" world!" @@ -1835,12 +1805,7 @@ a_dict = { } # Regression test for https://github.com/psf/black/issues/3506. -s = ( - "With single quote: ' " - f" {my_dict['foo']}" - ' With double quote: " ' - f" {my_dict['bar']}" -) +s = f"With single quote: ' {my_dict['foo']} With double quote: \" {my_dict['bar']}" s = f"Lorem Ipsum is simply dummy text of the printing and typesetting industry:'{my_dict['foo']}'" ``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__type_annotations.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__type_annotations.py.snap index 8b8220f9c47cb..da32d342f0cd2 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__type_annotations.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__type_annotations.py.snap @@ -45,8 +45,9 @@ def func( def func( - argument: "int |" "str", -+ argument: ("int |" "str"), - ) -> Set["int |" " str"]: +-) -> Set["int |" " str"]: ++ argument: ("int |str"), ++) -> Set["int | str"]: pass ``` @@ -76,8 +77,8 @@ def func( def func( - argument: ("int |" "str"), -) -> Set["int |" " str"]: + argument: ("int |str"), +) -> Set["int | str"]: pass ``` @@ -111,5 +112,3 @@ def func( ) -> Set["int |" " str"]: pass ``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_multiline_strings.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_multiline_strings.py.snap index 25ed182111884..68b0202991a58 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_multiline_strings.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_multiline_strings.py.snap @@ -366,22 +366,13 @@ actual: {some_var}""" [ """cow 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 @@ "c" ) -this_will_also_become_one_line = "abc" # comment +this_will_also_become_one_line = ( # comment -+ "a" "b" "c" ++ "abc" +) assert some_var == expected_result, """ @@ -632,7 +623,7 @@ Please use `--build-option` instead, `--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 = ( "a" # comment @@ -641,7 +632,7 @@ this_will_stay_on_three_lines = ( ) this_will_also_become_one_line = ( # comment - "a" "b" "c" + "abc" ) assert some_var == expected_result, """ diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__trailing_commas_in_leading_parts.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__trailing_commas_in_leading_parts.py.snap index fd4942632e8a3..27cdd32b80c32 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__trailing_commas_in_leading_parts.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__trailing_commas_in_leading_parts.py.snap @@ -175,5 +175,3 @@ assert xxxxxxxxx.xxxxxxxxx.xxxxxxxxx( "xxx {xxxxxxxxx} xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ) ``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/format.snap b/crates/ruff_python_formatter/tests/snapshots/format.snap new file mode 100644 index 0000000000000..6f55c55302108 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format.snap @@ -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"'}" +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__binary_implicit_string.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__binary_implicit_string.py.snap index 5f3c84a8dfa69..585f222d40096 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__binary_implicit_string.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__binary_implicit_string.py.snap @@ -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") +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__bytes.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__bytes.py.snap index 7f98068782493..852c7b92282a9 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__bytes.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__bytes.py.snap @@ -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 ``` 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}" +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap index 1496a5e98cdc5..af2a324aa36a0 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap @@ -371,7 +371,7 @@ source_type = Python ``` ```python -(f"{one}" f"{two}") +(f"{one}{two}") 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 --- Stable +++ Preview +@@ -1,4 +1,4 @@ +-(f"{one}" f"{two}") ++(f"{one}{two}") + + + rf"Not-so-tricky \"quote" @@ -6,13 +6,13 @@ # Regression test for fstrings dropping comments result_f = ( @@ -1115,6 +1121,15 @@ _ = ( " f()\n" # XXX: The following line changes depending on whether the tests # 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 @@ x = f"{a}" x = f"{ diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string.py.snap new file mode 100644 index 0000000000000..bfab700e2e3f2 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string.py.snap @@ -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" +) +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string_assignment.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string_assignment.py.snap new file mode 100644 index 0000000000000..046264fe539e3 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string_assignment.py.snap @@ -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 +) +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string_preserve.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string_preserve.py.snap new file mode 100644 index 0000000000000..72e46d20bf8af --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string_preserve.py.snap @@ -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'"}" +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__string.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__string.py.snap index 96a35c577ad56..3303732eb9434 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__string.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__string.py.snap @@ -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 ``` 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 +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__yield.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__yield.py.snap index 0d591cc737f80..0649b92081271 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__yield.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__yield.py.snap @@ -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 +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@parentheses__opening_parentheses_comment_empty.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@parentheses__opening_parentheses_comment_empty.py.snap index 126da8e6e3800..7969cd7b7d29c 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@parentheses__opening_parentheses_comment_empty.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@parentheses__opening_parentheses_comment_empty.py.snap @@ -188,6 +188,3 @@ f3 = { # f3 { # f 4 } ``` - - - diff --git a/crates/ruff_python_formatter/tests/snapshots/format@pattern__pattern_maybe_parenthesize.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@pattern__pattern_maybe_parenthesize.py.snap index 29004a1548d04..89c82dd396ddd 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@pattern__pattern_maybe_parenthesize.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@pattern__pattern_maybe_parenthesize.py.snap @@ -831,7 +831,51 @@ match x: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb, 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 match x: diff --git a/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap index d00f653417462..68e97d27f0bc7 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap @@ -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 ``` 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 ``` 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(): +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@statement__assert.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@statement__assert.py.snap index 0193312898a7f..2bc958df25c17 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@statement__assert.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@statement__assert.py.snap @@ -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 +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_no_parameters.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_no_parameters.py.snap index 2032db2308701..01cc4a19ebf42 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_no_parameters.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_no_parameters.py.snap @@ -397,6 +397,21 @@ def f() -> ( 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 @@ # 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 diff --git a/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_parameters.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_parameters.py.snap index ca5a99fc920b6..20d605b884317 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_parameters.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_parameters.py.snap @@ -412,3 +412,26 @@ def test_return_multiline_string_binary_expression_return_type_annotation( ]: 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 + + +```