diff --git a/crates/ruff_formatter/src/printer/mod.rs b/crates/ruff_formatter/src/printer/mod.rs index e35c44ec28ef2..69504bacf85fb 100644 --- a/crates/ruff_formatter/src/printer/mod.rs +++ b/crates/ruff_formatter/src/printer/mod.rs @@ -1472,6 +1472,11 @@ impl<'a, 'print> FitsMeasurer<'a, 'print> { } fn fits_text(&mut self, text: Text, args: PrintElementArgs) -> Fits { + fn exceeds_width(fits: &FitsMeasurer, args: PrintElementArgs) -> bool { + fits.state.line_width > fits.options().line_width.into() + && !args.measure_mode().allows_text_overflow() + } + let indent = std::mem::take(&mut self.state.pending_indent); self.state.line_width += u32::from(indent.level()) * self.options().indent_width() + u32::from(indent.align()); @@ -1493,7 +1498,13 @@ impl<'a, 'print> FitsMeasurer<'a, 'print> { return Fits::No; } match args.measure_mode() { - MeasureMode::FirstLine => return Fits::Yes, + MeasureMode::FirstLine => { + return if exceeds_width(self, args) { + Fits::No + } else { + Fits::Yes + }; + } MeasureMode::AllLines | MeasureMode::AllLinesAllowTextOverflow => { self.state.line_width = 0; @@ -1511,9 +1522,7 @@ impl<'a, 'print> FitsMeasurer<'a, 'print> { } } - if self.state.line_width > self.options().line_width.into() - && !args.measure_mode().allows_text_overflow() - { + if exceeds_width(self, args) { return Fits::No; } diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/multiline_string_deviations.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/multiline_string_deviations.py new file mode 100644 index 0000000000000..0537931402fce --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/multiline_string_deviations.py @@ -0,0 +1,43 @@ +# This file documents the deviations for formatting multiline strings with black. + +# Black hugs the parentheses for `%` usages -> convert to fstring. +# Can get unreadable if the arguments split +# This could be solved by using `best_fitting` to try to format the arguments on a single +# line. Let's consider adding this later. +# ```python +# call( +# 3, +# "dogsay", +# textwrap.dedent( +# """dove +# coo""" % "cowabunga", +# more, +# and_more, +# "aaaaaaa", +# "bbbbbbbbb", +# "cccccccc", +# ), +# ) +# ``` +call(3, "dogsay", textwrap.dedent("""dove + coo""" % "cowabunga")) + +# Black applies the hugging recursively. We don't (consistent with the hugging style). +path.write_text(textwrap.dedent("""\ + A triple-quoted string + actually leveraging the textwrap.dedent functionality + that ends in a trailing newline, + representing e.g. file contents. +""")) + + + +# Black avoids parenthesizing the following lambda. We could potentially support +# this by changing `Lambda::needs_parentheses` to return `BestFit` but it causes +# issues when the lambda has comments. +# Let's keep this as a known deviation for now. +generated_readme = lambda project_name: """ +{} + + +""".strip().format(project_name) diff --git a/crates/ruff_python_formatter/src/expression/binary_like.rs b/crates/ruff_python_formatter/src/expression/binary_like.rs index d1233bd3a497a..828b1e73b7c19 100644 --- a/crates/ruff_python_formatter/src/expression/binary_like.rs +++ b/crates/ruff_python_formatter/src/expression/binary_like.rs @@ -394,12 +394,12 @@ impl Format> for BinaryLike<'_> { f, [ operand.leading_binary_comments().map(leading_comments), - leading_comments(comments.leading(&string_constant)), + leading_comments(comments.leading(string_constant)), // Call `FormatStringContinuation` directly to avoid formatting // the implicitly concatenated string with the enclosing group // because the group is added by the binary like formatting. FormatStringContinuation::new(&string_constant), - trailing_comments(comments.trailing(&string_constant)), + trailing_comments(comments.trailing(string_constant)), operand.trailing_binary_comments().map(trailing_comments), line_suffix_boundary(), ] @@ -413,12 +413,12 @@ impl Format> for BinaryLike<'_> { write!( f, [ - leading_comments(comments.leading(&string_constant)), + leading_comments(comments.leading(string_constant)), // Call `FormatStringContinuation` directly to avoid formatting // the implicitly concatenated string with the enclosing group // because the group is added by the binary like formatting. FormatStringContinuation::new(&string_constant), - trailing_comments(comments.trailing(&string_constant)), + trailing_comments(comments.trailing(string_constant)), ] )?; } diff --git a/crates/ruff_python_formatter/src/expression/expr_bin_op.rs b/crates/ruff_python_formatter/src/expression/expr_bin_op.rs index 337dd825f2f4f..c9a331f2d9701 100644 --- a/crates/ruff_python_formatter/src/expression/expr_bin_op.rs +++ b/crates/ruff_python_formatter/src/expression/expr_bin_op.rs @@ -3,10 +3,10 @@ use ruff_python_ast::ExprBinOp; use crate::comments::SourceComment; use crate::expression::binary_like::BinaryLike; -use crate::expression::expr_string_literal::is_multiline_string; use crate::expression::has_parentheses; use crate::expression::parentheses::{NeedsParentheses, OptionalParentheses}; use crate::prelude::*; +use crate::string::AnyString; #[derive(Default)] pub struct FormatExprBinOp; @@ -35,13 +35,13 @@ impl NeedsParentheses for ExprBinOp { ) -> OptionalParentheses { if parent.is_expr_await() { OptionalParentheses::Always - } else if let Some(literal_expr) = self.left.as_literal_expr() { + } else if let Some(string) = AnyString::from_expression(&self.left) { // Multiline strings are guaranteed to never fit, avoid adding unnecessary parentheses - if !literal_expr.is_implicit_concatenated() - && is_multiline_string(literal_expr.into(), context.source()) + if !string.is_implicit_concatenated() + && string.is_multiline(context.source()) && has_parentheses(&self.right, context).is_some() && !context.comments().has_dangling(self) - && !context.comments().has(literal_expr) + && !context.comments().has(string) && !context.comments().has(self.right.as_ref()) { OptionalParentheses::Never 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 4869a2d536908..bc0ba26bd6667 100644 --- a/crates/ruff_python_formatter/src/expression/expr_bytes_literal.rs +++ b/crates/ruff_python_formatter/src/expression/expr_bytes_literal.rs @@ -2,7 +2,6 @@ use ruff_python_ast::AnyNodeRef; use ruff_python_ast::ExprBytesLiteral; use crate::comments::SourceComment; -use crate::expression::expr_string_literal::is_multiline_string; use crate::expression::parentheses::{ in_parentheses_only_group, NeedsParentheses, OptionalParentheses, }; @@ -41,7 +40,7 @@ impl NeedsParentheses for ExprBytesLiteral { ) -> OptionalParentheses { if self.value.is_implicit_concatenated() { OptionalParentheses::Multiline - } else if is_multiline_string(self.into(), context.source()) { + } else if AnyString::Bytes(self).is_multiline(context.source()) { OptionalParentheses::Never } else { OptionalParentheses::BestFit diff --git a/crates/ruff_python_formatter/src/expression/expr_compare.rs b/crates/ruff_python_formatter/src/expression/expr_compare.rs index e9a338075e318..5c654cd02a363 100644 --- a/crates/ruff_python_formatter/src/expression/expr_compare.rs +++ b/crates/ruff_python_formatter/src/expression/expr_compare.rs @@ -4,10 +4,10 @@ use ruff_python_ast::{CmpOp, ExprCompare}; use crate::comments::SourceComment; use crate::expression::binary_like::BinaryLike; -use crate::expression::expr_string_literal::is_multiline_string; use crate::expression::has_parentheses; use crate::expression::parentheses::{NeedsParentheses, OptionalParentheses}; use crate::prelude::*; +use crate::string::AnyString; #[derive(Default)] pub struct FormatExprCompare; @@ -37,11 +37,11 @@ impl NeedsParentheses for ExprCompare { ) -> OptionalParentheses { if parent.is_expr_await() { OptionalParentheses::Always - } else if let Some(literal_expr) = self.left.as_literal_expr() { + } else if let Some(string) = AnyString::from_expression(&self.left) { // Multiline strings are guaranteed to never fit, avoid adding unnecessary parentheses - if !literal_expr.is_implicit_concatenated() - && is_multiline_string(literal_expr.into(), context.source()) - && !context.comments().has(literal_expr) + if !string.is_implicit_concatenated() + && string.is_multiline(context.source()) + && !context.comments().has(string) && self.comparators.first().is_some_and(|right| { has_parentheses(right, context).is_some() && !context.comments().has(right) }) 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 8a8ac81d3524e..dcbb85520e9d1 100644 --- a/crates/ruff_python_formatter/src/expression/expr_f_string.rs +++ b/crates/ruff_python_formatter/src/expression/expr_f_string.rs @@ -1,5 +1,3 @@ -use memchr::memchr2; - use ruff_python_ast::{AnyNodeRef, ExprFString}; use ruff_source_file::Locator; use ruff_text_size::Ranged; @@ -50,10 +48,10 @@ impl NeedsParentheses for ExprFString { ) -> OptionalParentheses { if self.value.is_implicit_concatenated() { OptionalParentheses::Multiline - } else if memchr2(b'\n', b'\r', context.source()[self.range].as_bytes()).is_none() { - OptionalParentheses::BestFit - } else { + } else if AnyString::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 442081886d2ca..5248c325716ef 100644 --- a/crates/ruff_python_formatter/src/expression/expr_string_literal.rs +++ b/crates/ruff_python_formatter/src/expression/expr_string_literal.rs @@ -1,6 +1,5 @@ use ruff_formatter::FormatRuleWithOptions; use ruff_python_ast::{AnyNodeRef, ExprStringLiteral}; -use ruff_text_size::{Ranged, TextLen, TextRange}; use crate::comments::SourceComment; use crate::expression::parentheses::{ @@ -8,7 +7,7 @@ use crate::expression::parentheses::{ }; use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind}; use crate::prelude::*; -use crate::string::{AnyString, FormatStringContinuation, StringPrefix, StringQuotes}; +use crate::string::{AnyString, FormatStringContinuation}; #[derive(Default)] pub struct FormatExprStringLiteral { @@ -80,24 +79,10 @@ impl NeedsParentheses for ExprStringLiteral { ) -> OptionalParentheses { if self.value.is_implicit_concatenated() { OptionalParentheses::Multiline - } else if is_multiline_string(self.into(), context.source()) { + } else if AnyString::String(self).is_multiline(context.source()) { OptionalParentheses::Never } else { OptionalParentheses::BestFit } } } - -pub(super) fn is_multiline_string(expr: AnyNodeRef, source: &str) -> bool { - if expr.is_expr_string_literal() || expr.is_expr_bytes_literal() { - let contents = &source[expr.range()]; - let prefix = StringPrefix::parse(contents); - let quotes = - StringQuotes::parse(&contents[TextRange::new(prefix.text_len(), contents.text_len())]); - - quotes.is_some_and(StringQuotes::is_triple) - && memchr::memchr2(b'\n', b'\r', contents.as_bytes()).is_some() - } else { - false - } -} diff --git a/crates/ruff_python_formatter/src/expression/mod.rs b/crates/ruff_python_formatter/src/expression/mod.rs index 5868edf32d719..d22fdecda2baf 100644 --- a/crates/ruff_python_formatter/src/expression/mod.rs +++ b/crates/ruff_python_formatter/src/expression/mod.rs @@ -17,11 +17,14 @@ use crate::context::{NodeLevel, WithNodeLevel}; use crate::expression::expr_generator_exp::is_generator_parenthesized; use crate::expression::expr_tuple::is_tuple_parenthesized; use crate::expression::parentheses::{ - is_expression_parenthesized, optional_parentheses, parenthesized, NeedsParentheses, - OptionalParentheses, Parentheses, Parenthesize, + is_expression_parenthesized, optional_parentheses, parenthesized, HuggingStyle, + NeedsParentheses, OptionalParentheses, Parentheses, Parenthesize, }; use crate::prelude::*; -use crate::preview::is_hug_parens_with_braces_and_square_brackets_enabled; +use crate::preview::{ + is_hug_parens_with_braces_and_square_brackets_enabled, is_multiline_string_handling_enabled, +}; +use crate::string::AnyString; mod binary_like; pub(crate) mod expr_attribute; @@ -126,7 +129,7 @@ impl FormatRule> for FormatExpr { let node_comments = comments.leading_dangling_trailing(expression); if !node_comments.has_leading() && !node_comments.has_trailing() { parenthesized("(", &format_expr, ")") - .with_indent(!is_expression_huggable(expression, f.context())) + .with_hugging(is_expression_huggable(expression, f.context())) .fmt(f) } else { format_with_parentheses_comments(expression, &node_comments, f) @@ -444,7 +447,7 @@ impl Format> for MaybeParenthesizeExpression<'_> { OptionalParentheses::Never => match parenthesize { Parenthesize::IfBreaksOrIfRequired => { parenthesize_if_expands(&expression.format().with_options(Parentheses::Never)) - .with_indent(!is_expression_huggable(expression, f.context())) + .with_indent(is_expression_huggable(expression, f.context()).is_none()) .fmt(f) } @@ -1084,7 +1087,7 @@ pub(crate) fn has_own_parentheses( } /// Returns `true` if the expression can hug directly to enclosing parentheses, as in Black's -/// `hug_parens_with_braces_and_square_brackets` preview style behavior. +/// `hug_parens_with_braces_and_square_brackets` or `multiline_string_handling` preview styles behavior. /// /// For example, in preview style, given: /// ```python @@ -1110,11 +1113,10 @@ pub(crate) fn has_own_parentheses( /// ] /// ) /// ``` -pub(crate) fn is_expression_huggable(expr: &Expr, context: &PyFormatContext) -> bool { - if !is_hug_parens_with_braces_and_square_brackets_enabled(context) { - return false; - } - +pub(crate) fn is_expression_huggable( + expr: &Expr, + context: &PyFormatContext, +) -> Option { match expr { Expr::Tuple(_) | Expr::List(_) @@ -1122,18 +1124,14 @@ pub(crate) fn is_expression_huggable(expr: &Expr, context: &PyFormatContext) -> | Expr::Dict(_) | Expr::ListComp(_) | Expr::SetComp(_) - | Expr::DictComp(_) => true, - - Expr::Starred(ast::ExprStarred { value, .. }) => matches!( - value.as_ref(), - Expr::Tuple(_) - | Expr::List(_) - | Expr::Set(_) - | Expr::Dict(_) - | Expr::ListComp(_) - | Expr::SetComp(_) - | Expr::DictComp(_) - ), + | Expr::DictComp(_) => is_hug_parens_with_braces_and_square_brackets_enabled(context) + .then_some(HuggingStyle::Always), + + Expr::Starred(ast::ExprStarred { value, .. }) => is_expression_huggable(value, context), + + 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(_) @@ -1147,18 +1145,28 @@ pub(crate) fn is_expression_huggable(expr: &Expr, context: &PyFormatContext) -> | Expr::YieldFrom(_) | Expr::Compare(_) | Expr::Call(_) - | Expr::FString(_) | Expr::Attribute(_) | Expr::Subscript(_) | Expr::Name(_) | Expr::Slice(_) | Expr::IpyEscapeCommand(_) - | Expr::StringLiteral(_) - | Expr::BytesLiteral(_) | Expr::NumberLiteral(_) | Expr::BooleanLiteral(_) | Expr::NoneLiteral(_) - | Expr::EllipsisLiteral(_) => false, + | Expr::EllipsisLiteral(_) => None, + } +} + +/// Returns `true` if `string` is a multiline string that is not implicitly concatenated. +fn is_huggable_string(string: AnyString, context: &PyFormatContext) -> Option { + if !is_multiline_string_handling_enabled(context) { + return None; + } + + if !string.is_implicit_concatenated() && string.is_multiline(context.source()) { + Some(HuggingStyle::IfFirstLineFits) + } else { + None } } diff --git a/crates/ruff_python_formatter/src/expression/parentheses.rs b/crates/ruff_python_formatter/src/expression/parentheses.rs index df06a27a19541..3baa1d2aead4c 100644 --- a/crates/ruff_python_formatter/src/expression/parentheses.rs +++ b/crates/ruff_python_formatter/src/expression/parentheses.rs @@ -126,7 +126,7 @@ where FormatParenthesized { left, comments: &[], - indent: true, + hug: None, content: Argument::new(content), right, } @@ -135,7 +135,7 @@ where pub(crate) struct FormatParenthesized<'content, 'ast> { left: &'static str, comments: &'content [SourceComment], - indent: bool, + hug: Option, content: Argument<'content, PyFormatContext<'ast>>, right: &'static str, } @@ -158,8 +158,11 @@ impl<'content, 'ast> FormatParenthesized<'content, 'ast> { } /// Whether to indent the content within the parentheses. - pub(crate) fn with_indent(self, indent: bool) -> FormatParenthesized<'content, 'ast> { - FormatParenthesized { indent, ..self } + pub(crate) fn with_hugging( + self, + hug: Option, + ) -> FormatParenthesized<'content, 'ast> { + FormatParenthesized { hug, ..self } } } @@ -167,17 +170,41 @@ impl<'ast> Format> for FormatParenthesized<'_, 'ast> { fn fmt(&self, f: &mut Formatter>) -> FormatResult<()> { let current_level = f.context().node_level(); - let content = format_with(|f| { - group(&format_with(|f| { - dangling_open_parenthesis_comments(self.comments).fmt(f)?; - if self.indent || !self.comments.is_empty() { - soft_block_indent(&Arguments::from(&self.content)).fmt(f)?; - } else { - Arguments::from(&self.content).fmt(f)?; + let indented = format_with(|f| { + let content = Arguments::from(&self.content); + if self.comments.is_empty() { + match self.hug { + None => group(&soft_block_indent(&content)).fmt(f), + Some(HuggingStyle::Always) => content.fmt(f), + Some(HuggingStyle::IfFirstLineFits) => { + // It's not immediately obvious how the below IR works to only indent the content if the first line exceeds the configured line width. + // The trick is the first group that doesn't wrap `self.content`. + // * The group doesn't wrap `self.content` because we need to assume that `self.content` + // contains a hard line break and hard-line-breaks always expand the enclosing group. + // * The printer decides that a group fits if its content (in this case a `soft_line_break` that has a width of 0 and is guaranteed to fit) + // and the content coming after the group in expanded mode (`self.content`) fits on the line. + // The content coming after fits if the content up to the first soft or hard line break (or the end of the document) fits. + // + // This happens to be right what we want. The first group should add an indent and a soft line break if the content of `self.content` + // up to the first line break exceeds the configured line length, but not otherwise. + let indented = f.group_id("indented_content"); + write!( + f, + [ + group(&indent(&soft_line_break())).with_group_id(Some(indented)), + indent_if_group_breaks(&content, indented), + if_group_breaks(&soft_line_break()).with_group_id(Some(indented)) + ] + ) + } } - Ok(()) - })) - .fmt(f) + } else { + group(&format_args![ + dangling_open_parenthesis_comments(self.comments), + soft_block_indent(&content), + ]) + .fmt(f) + } }); let inner = format_with(|f| { @@ -186,12 +213,12 @@ impl<'ast> Format> for FormatParenthesized<'_, 'ast> { // This ensures that expanding this parenthesized expression does not expand the optional parentheses group. write!( f, - [fits_expanded(&content) + [fits_expanded(&indented) .with_condition(Some(Condition::if_group_fits_on_line(group_id)))] ) } else { // It's not necessary to wrap the content if it is not inside of an optional_parentheses group. - content.fmt(f) + indented.fmt(f) } }); @@ -201,6 +228,20 @@ impl<'ast> Format> for FormatParenthesized<'_, 'ast> { } } +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub(crate) enum HuggingStyle { + /// Always hug the content (never indent). + Always, + + /// Hug the content if the content up to the first line break fits into the configured line length. Otherwise indent the content. + /// + /// This is different from [`HuggingStyle::Always`] in that it doesn't indent if the content contains a hard line break, and the content up to that hard line break fits into the configured line length. + /// + /// This style is used for formatting multiline strings that, by definition, always break. The idea is to + /// only hug a multiline string if its content up to the first line breaks exceeds the configured line length. + IfFirstLineFits, +} + /// Wraps an expression in parentheses only if it still does not fit after expanding all expressions that start or end with /// a parentheses (`()`, `[]`, `{}`). pub(crate) fn optional_parentheses<'content, 'ast, Content>( diff --git a/crates/ruff_python_formatter/src/other/arguments.rs b/crates/ruff_python_formatter/src/other/arguments.rs index d57e168c89459..393d8e45003d2 100644 --- a/crates/ruff_python_formatter/src/other/arguments.rs +++ b/crates/ruff_python_formatter/src/other/arguments.rs @@ -6,10 +6,11 @@ use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::comments::SourceComment; use crate::expression::expr_generator_exp::GeneratorExpParentheses; use crate::expression::is_expression_huggable; -use crate::expression::parentheses::{empty_parenthesized, parenthesized, Parentheses}; +use crate::expression::parentheses::{ + empty_parenthesized, parenthesized, HuggingStyle, Parentheses, +}; use crate::other::commas; use crate::prelude::*; -use crate::preview::is_hug_parens_with_braces_and_square_brackets_enabled; #[derive(Default)] pub struct FormatArguments; @@ -107,7 +108,7 @@ impl FormatNodeRule for FormatArguments { // ) // ``` parenthesized("(", &group(&all_arguments), ")") - .with_indent(!is_argument_huggable(item, f.context())) + .with_hugging(is_arguments_huggable(item, f.context())) .with_dangling_comments(dangling_comments) ] ) @@ -177,29 +178,23 @@ fn is_single_argument_parenthesized(argument: &Expr, call_end: TextSize, source: /// /// Hugging should only be applied to single-argument collections, like lists, or starred versions /// of those collections. -fn is_argument_huggable(item: &Arguments, context: &PyFormatContext) -> bool { - if !is_hug_parens_with_braces_and_square_brackets_enabled(context) { - return false; - } - +fn is_arguments_huggable(item: &Arguments, context: &PyFormatContext) -> Option { // Find the lone argument or `**kwargs` keyword. let arg = match (item.args.as_slice(), item.keywords.as_slice()) { ([arg], []) => arg, ([], [keyword]) if keyword.arg.is_none() && !context.comments().has(keyword) => { &keyword.value } - _ => return false, + _ => return None, }; // If the expression itself isn't huggable, then we can't hug it. - if !is_expression_huggable(arg, context) { - return false; - } + let hugging_style = is_expression_huggable(arg, context)?; // If the expression has leading or trailing comments, then we can't hug it. let comments = context.comments().leading_dangling_trailing(arg); if comments.has_leading() || comments.has_trailing() { - return false; + return None; } let options = context.options(); @@ -208,8 +203,8 @@ fn is_argument_huggable(item: &Arguments, context: &PyFormatContext) -> bool { if options.magic_trailing_comma().is_respect() && commas::has_magic_trailing_comma(TextRange::new(arg.end(), item.end()), options, context) { - return false; + return None; } - true + Some(hugging_style) } diff --git a/crates/ruff_python_formatter/src/preview.rs b/crates/ruff_python_formatter/src/preview.rs index 1e2b8ae36bff6..f610da5fd41d3 100644 --- a/crates/ruff_python_formatter/src/preview.rs +++ b/crates/ruff_python_formatter/src/preview.rs @@ -62,3 +62,8 @@ pub(crate) const fn is_dummy_implementations_enabled(context: &PyFormatContext) pub(crate) const fn is_hex_codes_in_unicode_sequences_enabled(context: &PyFormatContext) -> bool { context.is_preview() } + +/// Returns `true` if the [`multiline_string_handling`](https://github.com/astral-sh/ruff/issues/8896) preview style is enabled. +pub(crate) const fn is_multiline_string_handling_enabled(context: &PyFormatContext) -> bool { + context.is_preview() +} diff --git a/crates/ruff_python_formatter/src/string/mod.rs b/crates/ruff_python_formatter/src/string/mod.rs index 1c06fff690fb5..c41716427b34d 100644 --- a/crates/ruff_python_formatter/src/string/mod.rs +++ b/crates/ruff_python_formatter/src/string/mod.rs @@ -1,6 +1,7 @@ use std::borrow::Cow; use bitflags::bitflags; +use memchr::memchr2; use ruff_formatter::{format_args, write}; use ruff_python_ast::AnyNodeRef; @@ -29,7 +30,7 @@ pub(crate) enum Quoting { /// Represents any kind of string expression. This could be either a string, /// bytes or f-string. -#[derive(Clone, Debug)] +#[derive(Copy, Clone, Debug)] pub(crate) enum AnyString<'a> { String(&'a ExprStringLiteral), Bytes(&'a ExprBytesLiteral), @@ -50,7 +51,7 @@ impl<'a> AnyString<'a> { } /// Returns `true` if the string is implicitly concatenated. - pub(crate) fn is_implicit_concatenated(&self) -> bool { + pub(crate) fn is_implicit_concatenated(self) -> bool { match self { Self::String(ExprStringLiteral { value, .. }) => value.is_implicit_concatenated(), Self::Bytes(ExprBytesLiteral { value, .. }) => value.is_implicit_concatenated(), @@ -59,7 +60,7 @@ impl<'a> AnyString<'a> { } /// Returns the quoting to be used for this string. - fn quoting(&self, locator: &Locator<'_>) -> Quoting { + fn quoting(self, locator: &Locator<'_>) -> Quoting { match self { Self::String(_) | Self::Bytes(_) => Quoting::CanChange, Self::FString(f_string) => f_string_quoting(f_string, locator), @@ -67,7 +68,7 @@ impl<'a> AnyString<'a> { } /// Returns a vector of all the [`AnyStringPart`] of this string. - fn parts(&self, quoting: Quoting) -> Vec> { + fn parts(self, quoting: Quoting) -> Vec> { match self { Self::String(ExprStringLiteral { value, .. }) => value .iter() @@ -94,6 +95,24 @@ impl<'a> AnyString<'a> { .collect(), } } + + pub(crate) fn is_multiline(self, source: &str) -> bool { + match self { + AnyString::String(_) | AnyString::Bytes(_) => { + let contents = &source[self.range()]; + let prefix = StringPrefix::parse(contents); + let quotes = StringQuotes::parse( + &contents[TextRange::new(prefix.text_len(), contents.text_len())], + ); + + quotes.is_some_and(StringQuotes::is_triple) + && memchr2(b'\n', b'\r', contents.as_bytes()).is_some() + } + AnyString::FString(fstring) => { + memchr2(b'\n', b'\r', source[fstring.range].as_bytes()).is_some() + } + } + } } impl Ranged for AnyString<'_> { @@ -116,6 +135,12 @@ impl<'a> From<&AnyString<'a>> for AnyNodeRef<'a> { } } +impl<'a> From> for AnyNodeRef<'a> { + fn from(value: AnyString<'a>) -> Self { + AnyNodeRef::from(&value) + } +} + impl<'a> From<&AnyString<'a>> for ExpressionRef<'a> { fn from(value: &AnyString<'a>) -> Self { match value { 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 9e841c8b21eee..92631d00ed696 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,95 +1,138 @@ +@@ -1,46 +1,69 @@ -"""cow +( + """cow @@ -271,41 +271,21 @@ this_will_also_become_one_line = ( # comment + ), ) 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.""") --textwrap.dedent("""\ -+but also goes one line beyond.""" -+) -+textwrap.dedent( -+ """\ - A triple-quoted string - actually leveraging the textwrap.dedent functionality + textwrap.dedent("""A two-line triple-quoted string +@@ -54,18 +77,24 @@ that ends in a trailing newline, representing e.g. file contents. --""") + """) -path.write_text(textwrap.dedent("""\ -+""" -+) +path.write_text( -+ textwrap.dedent( -+ """\ ++ textwrap.dedent("""\ A triple-quoted string actually leveraging the textwrap.dedent functionality that ends in a trailing newline, representing e.g. file contents. -""")) -path.write_text(textwrap.dedent("""\ -+""" -+ ) ++""") +) +path.write_text( + textwrap.dedent( @@ -319,29 +299,9 @@ this_will_also_become_one_line = ( # comment + ) +) # Another use case --data = yaml.load("""\ -+data = yaml.load( -+ """\ - a: 1 - b: 2 --""") -+""" -+) - data = yaml.load( - """\ + data = yaml.load("""\ a: 1 - b: 2 - """, - ) --data = yaml.load("""\ -+data = yaml.load( -+ """\ - a: 1 - b: 2 --""") -+""" -+) - +@@ -85,11 +114,13 @@ MULTILINE = """ foo """.replace("\n", "") @@ -356,7 +316,7 @@ this_will_also_become_one_line = ( # comment parser.usage += """ Custom extra help summary. -@@ -156,16 +199,24 @@ +@@ -156,16 +187,24 @@ 10 LOAD_CONST 0 (None) 12 RETURN_VALUE """ % (_C.__init__.__code__.co_firstlineno + 1,) @@ -387,36 +347,7 @@ this_will_also_become_one_line = ( # comment [ """cow moos""", -@@ -177,28 +228,32 @@ - - - def dastardly_default_value( -- cow: String = json.loads("""this -+ cow: String = json.loads( -+ """this - is - quite - the - dastadardly --value!"""), -+value!""" -+ ), - **kwargs, - ): - pass - - --print(f""" -+print( -+ f""" - This {animal} - moos and barks - {animal} say --""") -+""" -+) - msg = f"""The arguments {bad_arguments} were passed in. - Please use `--build-option` instead, +@@ -198,7 +237,7 @@ `--global-option` is reserved to flags like `--verbose` or `--quiet`. """ @@ -425,7 +356,7 @@ this_will_also_become_one_line = ( # comment this_will_stay_on_three_lines = ( "a" # comment -@@ -206,4 +261,6 @@ +@@ -206,4 +245,6 @@ "c" ) @@ -506,32 +437,24 @@ 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.""" -) -textwrap.dedent( - """\ +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. -""" -) +""") path.write_text( - textwrap.dedent( - """\ + textwrap.dedent("""\ A triple-quoted string actually leveraging the textwrap.dedent functionality that ends in a trailing newline, representing e.g. file contents. -""" - ) +""") ) path.write_text( textwrap.dedent( @@ -544,24 +467,20 @@ path.write_text( ) ) # Another use case -data = yaml.load( - """\ +data = yaml.load("""\ a: 1 b: 2 -""" -) +""") data = yaml.load( """\ a: 1 b: 2 """, ) -data = yaml.load( - """\ +data = yaml.load("""\ a: 1 b: 2 -""" -) +""") MULTILINE = """ foo @@ -668,26 +587,22 @@ barks""", def dastardly_default_value( - cow: String = json.loads( - """this + cow: String = json.loads("""this is quite the dastadardly -value!""" - ), +value!"""), **kwargs, ): pass -print( - f""" +print(f""" This {animal} moos and barks {animal} say -""" -) +""") msg = f"""The arguments {bad_arguments} were passed in. Please use `--build-option` instead, `--global-option` is reserved to flags like `--verbose` or `--quiet`. 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 8ba9866a4a46c..ca55222235a32 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 @@ -8209,6 +8209,42 @@ def markdown_skipped_rst_directive(): ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -480,10 +480,8 @@ + Do cool stuff:: + + if True: +- cool_stuff( +- ''' +- hiya''' +- ) ++ cool_stuff(''' ++ hiya''') + + Done. + """ +@@ -958,13 +956,11 @@ + Do cool stuff. + + `````` +- do_something( +- ''' ++ do_something(''' + ``` + did i trick you? + ``` +- ''' +- ) ++ ''') + `````` + + Done. +``` + + ### Output 6 ``` indent-style = space @@ -9577,6 +9613,42 @@ def markdown_skipped_rst_directive(): ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -480,10 +480,8 @@ + Do cool stuff:: + + if True: +- cool_stuff( +- ''' +- hiya''' +- ) ++ cool_stuff(''' ++ hiya''') + + Done. + """ +@@ -958,13 +956,11 @@ + Do cool stuff. + + `````` +- do_something( +- ''' ++ do_something(''' + ``` + did i trick you? + ``` +- ''' +- ) ++ ''') + `````` + + Done. +``` + + ### Output 7 ``` indent-style = tab @@ -10954,6 +11026,42 @@ def markdown_skipped_rst_directive(): ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -489,10 +489,8 @@ + Do cool stuff:: + + if True: +- cool_stuff( +- ''' +- hiya''' +- ) ++ cool_stuff(''' ++ hiya''') + + Done. + """ +@@ -967,13 +965,11 @@ + Do cool stuff. + + `````` +- do_something( +- ''' ++ do_something(''' + ``` + did i trick you? + ``` +- ''' +- ) ++ ''') + `````` + + Done. +``` + + ### Output 8 ``` indent-style = tab @@ -12322,6 +12430,42 @@ def markdown_skipped_rst_directive(): ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -480,10 +480,8 @@ + Do cool stuff:: + + if True: +- cool_stuff( +- ''' +- hiya''' +- ) ++ cool_stuff(''' ++ hiya''') + + Done. + """ +@@ -958,13 +956,11 @@ + Do cool stuff. + + `````` +- do_something( +- ''' ++ do_something(''' + ``` + did i trick you? + ``` +- ''' +- ) ++ ''') + `````` + + Done. +``` + + ### Output 9 ``` indent-style = space @@ -13699,6 +13843,42 @@ def markdown_skipped_rst_directive(): ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -489,10 +489,8 @@ + Do cool stuff:: + + if True: +- cool_stuff( +- ''' +- hiya''' +- ) ++ cool_stuff(''' ++ hiya''') + + Done. + """ +@@ -967,13 +965,11 @@ + Do cool stuff. + + `````` +- do_something( +- ''' ++ do_something(''' + ``` + did i trick you? + ``` +- ''' +- ) ++ ''') + `````` + + Done. +``` + + ### Output 10 ``` indent-style = space @@ -15067,4 +15247,40 @@ def markdown_skipped_rst_directive(): ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -480,10 +480,8 @@ + Do cool stuff:: + + if True: +- cool_stuff( +- ''' +- hiya''' +- ) ++ cool_stuff(''' ++ hiya''') + + Done. + """ +@@ -958,13 +956,11 @@ + Do cool stuff. + + `````` +- do_something( +- ''' ++ do_something(''' + ``` + did i trick you? + ``` +- ''' +- ) ++ ''') + `````` + + Done. +``` + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@multiline_string_deviations.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@multiline_string_deviations.py.snap new file mode 100644 index 0000000000000..6486e0be54777 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@multiline_string_deviations.py.snap @@ -0,0 +1,136 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/multiline_string_deviations.py +--- +## Input +```python +# This file documents the deviations for formatting multiline strings with black. + +# Black hugs the parentheses for `%` usages -> convert to fstring. +# Can get unreadable if the arguments split +# This could be solved by using `best_fitting` to try to format the arguments on a single +# line. Let's consider adding this later. +# ```python +# call( +# 3, +# "dogsay", +# textwrap.dedent( +# """dove +# coo""" % "cowabunga", +# more, +# and_more, +# "aaaaaaa", +# "bbbbbbbbb", +# "cccccccc", +# ), +# ) +# ``` +call(3, "dogsay", textwrap.dedent("""dove + coo""" % "cowabunga")) + +# Black applies the hugging recursively. We don't (consistent with the hugging style). +path.write_text(textwrap.dedent("""\ + A triple-quoted string + actually leveraging the textwrap.dedent functionality + that ends in a trailing newline, + representing e.g. file contents. +""")) + + + +# Black avoids parenthesizing the following lambda. We could potentially support +# this by changing `Lambda::needs_parentheses` to return `BestFit` but it causes +# issues when the lambda has comments. +# Let's keep this as a known deviation for now. +generated_readme = lambda project_name: """ +{} + + +""".strip().format(project_name) +``` + +## Output +```python +# This file documents the deviations for formatting multiline strings with black. + +# Black hugs the parentheses for `%` usages -> convert to fstring. +# Can get unreadable if the arguments split +# This could be solved by using `best_fitting` to try to format the arguments on a single +# line. Let's consider adding this later. +# ```python +# call( +# 3, +# "dogsay", +# textwrap.dedent( +# """dove +# coo""" % "cowabunga", +# more, +# and_more, +# "aaaaaaa", +# "bbbbbbbbb", +# "cccccccc", +# ), +# ) +# ``` +call( + 3, + "dogsay", + textwrap.dedent( + """dove + coo""" + % "cowabunga" + ), +) + +# Black applies the hugging recursively. We don't (consistent with the hugging style). +path.write_text( + textwrap.dedent( + """\ + A triple-quoted string + actually leveraging the textwrap.dedent functionality + that ends in a trailing newline, + representing e.g. file contents. +""" + ) +) + + +# Black avoids parenthesizing the following lambda. We could potentially support +# this by changing `Lambda::needs_parentheses` to return `BestFit` but it causes +# issues when the lambda has comments. +# Let's keep this as a known deviation for now. +generated_readme = ( + lambda project_name: """ +{} + + +""".strip().format(project_name) +) +``` + + +## Preview changes +```diff +--- Stable ++++ Preview +@@ -31,14 +31,12 @@ + + # Black applies the hugging recursively. We don't (consistent with the hugging style). + path.write_text( +- textwrap.dedent( +- """\ ++ textwrap.dedent("""\ + A triple-quoted string + actually leveraging the textwrap.dedent functionality + that ends in a trailing newline, + representing e.g. file contents. +-""" +- ) ++""") + ) + + +``` + + +