diff --git a/crates/ruff_python_formatter/src/expression/mod.rs b/crates/ruff_python_formatter/src/expression/mod.rs index edce8257d3dc0f..8244e1febd3aab 100644 --- a/crates/ruff_python_formatter/src/expression/mod.rs +++ b/crates/ruff_python_formatter/src/expression/mod.rs @@ -26,6 +26,7 @@ use crate::prelude::*; use crate::preview::{ is_hug_parens_with_braces_and_square_brackets_enabled, is_multiline_string_handling_enabled, }; +use crate::string::{AnyString, StringPart}; mod binary_like; pub(crate) mod expr_attribute; @@ -1126,24 +1127,9 @@ pub(crate) fn is_expression_huggable(expr: &Expr, context: &PyFormatContext) -> Expr::Starred(ast::ExprStarred { value, .. }) => is_expression_huggable(value, context), - Expr::StringLiteral(string) => { - is_multiline_string_handling_enabled(context) - && !string.value.is_implicit_concatenated() - && is_multiline_string( - LiteralExpressionRef::StringLiteral(string), - context.source(), - ) - } - Expr::BytesLiteral(bytes) => { - is_multiline_string_handling_enabled(context) - && !bytes.value.is_implicit_concatenated() - && is_multiline_string(LiteralExpressionRef::BytesLiteral(bytes), context.source()) - } - Expr::FString(fstring) => { - is_multiline_string_handling_enabled(context) - && !fstring.value.is_implicit_concatenated() - && is_multiline_fstring(fstring, context.source()) - } + Expr::StringLiteral(string) => is_huggable_string(AnyString::String(string), context), + Expr::BytesLiteral(bytes) => is_huggable_string(AnyString::Bytes(bytes), context), + Expr::FString(fstring) => is_huggable_string(AnyString::FString(fstring), context), Expr::BoolOp(_) | Expr::NamedExpr(_) @@ -1169,6 +1155,78 @@ pub(crate) fn is_expression_huggable(expr: &Expr, context: &PyFormatContext) -> } } +/// Returns `true` if `string` is a +/// * multiline string +/// * ...that is not implicitly concatenated +/// * ...where the opening and closing quotes have no content on the same line. +/// +/// ## Examples +/// +/// ```python +/// call(""" +/// ABCD +/// """) +/// ``` +/// +/// Returns `true` because the `"""` are n their own line +/// +/// ```python +/// call("""ABCD +/// More +/// """) +/// ``` +/// +/// Returns `false` because there's content on the same line as the opening quotes. +/// +/// ```python +/// call(""" +/// ABCD +/// More""" +/// ) +/// ``` +/// +/// Returns `false` because there's content on the same line as the closing quotes. +/// +/// ```python +/// call("""\ +/// ABCD +/// More +/// """) +/// ``` +/// +/// Returns `true` because there's only a line continuation after the opening quotes. +fn is_huggable_string(string: AnyString, context: &PyFormatContext) -> bool { + if string.is_implicit_concatenated() || !is_multiline_string_handling_enabled(context) { + return false; + } + + let multiline = match string { + AnyString::String(string) => is_multiline_string( + LiteralExpressionRef::StringLiteral(string), + context.source(), + ), + AnyString::Bytes(bytes) => { + is_multiline_string(LiteralExpressionRef::BytesLiteral(bytes), context.source()) + } + AnyString::FString(fstring) => is_multiline_fstring(fstring, context.source()), + }; + + if !multiline { + return false; + } + + let locator = context.locator(); + let part = StringPart::from_source(string.range(), &locator); + let source = part.source(&locator); + + source + // allow `"""\` + .strip_prefix('\\') + .unwrap_or(source) + .starts_with(['\n', '\r']) + && source.trim_end_matches(' ').ends_with('\n') +} + /// The precedence of [python operators](https://docs.python.org/3/reference/expressions.html#operator-precedence) from /// highest to lowest priority. /// diff --git a/crates/ruff_python_formatter/src/string/mod.rs b/crates/ruff_python_formatter/src/string/mod.rs index 1c06fff690fb5c..5e735ee7402a9a 100644 --- a/crates/ruff_python_formatter/src/string/mod.rs +++ b/crates/ruff_python_formatter/src/string/mod.rs @@ -311,7 +311,7 @@ impl StringPart { configured_style }; - let raw_content = &locator.slice(self.content_range); + let raw_content = self.source(locator); let quotes = match quoting { Quoting::Preserve => self.quotes, @@ -337,6 +337,10 @@ impl StringPart { quotes, } } + + pub(crate) fn source<'a>(&self, locator: &'a Locator) -> &'a str { + locator.slice(self.content_range) + } } #[derive(Debug)] 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 92631d00ed696f..1d6481e1fe67db 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 @@ -187,7 +187,7 @@ this_will_also_become_one_line = ( # comment ```diff --- Black +++ Ruff -@@ -1,46 +1,69 @@ +@@ -1,71 +1,104 @@ -"""cow +( + """cow @@ -271,8 +271,22 @@ this_will_also_become_one_line = ( # comment + ), ) textwrap.dedent("""A one-line triple-quoted string.""") - textwrap.dedent("""A two-line triple-quoted string -@@ -54,18 +77,24 @@ +-textwrap.dedent("""A two-line triple-quoted string +-since it goes to the next line.""") +-textwrap.dedent("""A three-line triple-quoted string ++textwrap.dedent( ++ """A two-line triple-quoted string ++since it goes to the next line.""" ++) ++textwrap.dedent( ++ """A three-line triple-quoted string + that not only goes to the next line +-but also goes one line beyond.""") ++but also goes one line beyond.""" ++) + textwrap.dedent("""\ + A triple-quoted string + actually leveraging the textwrap.dedent functionality that ends in a trailing newline, representing e.g. file contents. """) @@ -301,7 +315,7 @@ this_will_also_become_one_line = ( # comment # Another use case data = yaml.load("""\ a: 1 -@@ -85,11 +114,13 @@ +@@ -85,11 +118,13 @@ MULTILINE = """ foo """.replace("\n", "") @@ -316,7 +330,7 @@ this_will_also_become_one_line = ( # comment parser.usage += """ Custom extra help summary. -@@ -156,16 +187,24 @@ +@@ -156,16 +191,24 @@ 10 LOAD_CONST 0 (None) 12 RETURN_VALUE """ % (_C.__init__.__code__.co_firstlineno + 1,) @@ -347,7 +361,24 @@ this_will_also_become_one_line = ( # comment [ """cow moos""", -@@ -198,7 +237,7 @@ +@@ -177,12 +220,14 @@ + + + def dastardly_default_value( +- cow: String = json.loads("""this ++ cow: String = json.loads( ++ """this + is + quite + the + dastadardly +-value!"""), ++value!""" ++ ), + **kwargs, + ): + pass +@@ -198,7 +243,7 @@ `--global-option` is reserved to flags like `--verbose` or `--quiet`. """ @@ -356,7 +387,7 @@ this_will_also_become_one_line = ( # comment this_will_stay_on_three_lines = ( "a" # comment -@@ -206,4 +245,6 @@ +@@ -206,4 +251,6 @@ "c" ) @@ -437,11 +468,15 @@ call( ), ) textwrap.dedent("""A one-line triple-quoted string.""") -textwrap.dedent("""A two-line triple-quoted string -since it goes to the next line.""") -textwrap.dedent("""A three-line triple-quoted string +textwrap.dedent( + """A two-line triple-quoted string +since it goes to the next line.""" +) +textwrap.dedent( + """A three-line triple-quoted string that not only goes to the next line -but also goes one line beyond.""") +but also goes one line beyond.""" +) textwrap.dedent("""\ A triple-quoted string actually leveraging the textwrap.dedent functionality @@ -587,12 +622,14 @@ barks""", def dastardly_default_value( - cow: String = json.loads("""this + cow: String = json.loads( + """this is quite the dastadardly -value!"""), +value!""" + ), **kwargs, ): pass diff --git a/crates/ruff_python_formatter/tests/snapshots/format@docstring_code_examples.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@docstring_code_examples.py.snap index ca55222235a32a..e985f36af01eaf 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@docstring_code_examples.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@docstring_code_examples.py.snap @@ -8213,20 +8213,7 @@ def markdown_skipped_rst_directive(): ```diff --- Stable +++ Preview -@@ -480,10 +480,8 @@ - Do cool stuff:: - - if True: -- cool_stuff( -- ''' -- hiya''' -- ) -+ cool_stuff(''' -+ hiya''') - - Done. - """ -@@ -958,13 +956,11 @@ +@@ -958,13 +958,11 @@ Do cool stuff. `````` @@ -9617,20 +9604,7 @@ def markdown_skipped_rst_directive(): ```diff --- Stable +++ Preview -@@ -480,10 +480,8 @@ - Do cool stuff:: - - if True: -- cool_stuff( -- ''' -- hiya''' -- ) -+ cool_stuff(''' -+ hiya''') - - Done. - """ -@@ -958,13 +956,11 @@ +@@ -958,13 +958,11 @@ Do cool stuff. `````` @@ -11030,20 +11004,7 @@ def markdown_skipped_rst_directive(): ```diff --- Stable +++ Preview -@@ -489,10 +489,8 @@ - Do cool stuff:: - - if True: -- cool_stuff( -- ''' -- hiya''' -- ) -+ cool_stuff(''' -+ hiya''') - - Done. - """ -@@ -967,13 +965,11 @@ +@@ -967,13 +967,11 @@ Do cool stuff. `````` @@ -12434,20 +12395,7 @@ def markdown_skipped_rst_directive(): ```diff --- Stable +++ Preview -@@ -480,10 +480,8 @@ - Do cool stuff:: - - if True: -- cool_stuff( -- ''' -- hiya''' -- ) -+ cool_stuff(''' -+ hiya''') - - Done. - """ -@@ -958,13 +956,11 @@ +@@ -958,13 +958,11 @@ Do cool stuff. `````` @@ -13847,20 +13795,7 @@ def markdown_skipped_rst_directive(): ```diff --- Stable +++ Preview -@@ -489,10 +489,8 @@ - Do cool stuff:: - - if True: -- cool_stuff( -- ''' -- hiya''' -- ) -+ cool_stuff(''' -+ hiya''') - - Done. - """ -@@ -967,13 +965,11 @@ +@@ -967,13 +967,11 @@ Do cool stuff. `````` @@ -15251,20 +15186,7 @@ def markdown_skipped_rst_directive(): ```diff --- Stable +++ Preview -@@ -480,10 +480,8 @@ - Do cool stuff:: - - if True: -- cool_stuff( -- ''' -- hiya''' -- ) -+ cool_stuff(''' -+ hiya''') - - Done. - """ -@@ -958,13 +956,11 @@ +@@ -958,13 +958,11 @@ Do cool stuff. ``````