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_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/expr_bytes_literal.rs b/crates/ruff_python_formatter/src/expression/expr_bytes_literal.rs index 7b6837b655080..a311f2198f31f 100644 --- a/crates/ruff_python_formatter/src/expression/expr_bytes_literal.rs +++ b/crates/ruff_python_formatter/src/expression/expr_bytes_literal.rs @@ -1,3 +1,5 @@ +use ruff_formatter::FormatRuleWithOptions; +use ruff_formatter::GroupId; use ruff_python_ast::ExprBytesLiteral; use ruff_python_ast::{AnyNodeRef, StringLike}; @@ -8,7 +10,28 @@ use crate::prelude::*; use crate::string::{FormatImplicitConcatenatedString, StringLikeExtensions}; #[derive(Default)] -pub struct FormatExprBytesLiteral; +pub struct FormatExprBytesLiteral { + layout: ExprBytesLiteralLayout, +} + +#[derive(Default)] +pub struct ExprBytesLiteralLayout { + /// ID of the group wrapping the implicit concatenated string. If `None`, the implicit + /// is wrapped in an [`in_parentheses_only_group`]. + /// + /// This is used when formatting implicit concatenated strings in assignment value positions + /// where the positioning of comments depends on whether the string can be joined or not. + pub implicit_group_id: Option, +} + +impl FormatRuleWithOptions> for FormatExprBytesLiteral { + type Options = ExprBytesLiteralLayout; + + fn with_options(mut self, options: Self::Options) -> Self { + self.layout = options; + self + } +} impl FormatNodeRule for FormatExprBytesLiteral { fn fmt_fields(&self, item: &ExprBytesLiteral, f: &mut PyFormatter) -> FormatResult<()> { @@ -16,7 +39,14 @@ impl FormatNodeRule for FormatExprBytesLiteral { match value.as_slice() { [bytes_literal] => bytes_literal.format().fmt(f), - _ => in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f), + _ => match self.layout.implicit_group_id { + Some(group_id) => group(&FormatImplicitConcatenatedString::new(item)) + .with_group_id(Some(group_id)) + .fmt(f), + None => { + 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..68f45922e9771 100644 --- a/crates/ruff_python_formatter/src/expression/expr_f_string.rs +++ b/crates/ruff_python_formatter/src/expression/expr_f_string.rs @@ -1,3 +1,4 @@ +use ruff_formatter::{FormatRuleWithOptions, GroupId}; use ruff_python_ast::{AnyNodeRef, ExprFString, StringLike}; use ruff_source_file::Locator; use ruff_text_size::Ranged; @@ -10,7 +11,28 @@ use crate::prelude::*; use crate::string::{FormatImplicitConcatenatedString, Quoting, StringLikeExtensions}; #[derive(Default)] -pub struct FormatExprFString; +pub struct FormatExprFString { + layout: ExprFStringLayout, +} + +#[derive(Default)] +pub struct ExprFStringLayout { + /// ID of the group wrapping the implicit concatenated string. If `None`, the implicit + /// is wrapped in an [`in_parentheses_only_group`]. + /// + /// This is used when formatting implicit concatenated strings in assignment value positions + /// where the positioning of comments depends on whether the string can be joined or not. + pub implicit_group_id: Option, +} + +impl FormatRuleWithOptions> for FormatExprFString { + type Options = ExprFStringLayout; + + fn with_options(mut self, options: Self::Options) -> Self { + self.layout = options; + self + } +} impl FormatNodeRule for FormatExprFString { fn fmt_fields(&self, item: &ExprFString, f: &mut PyFormatter) -> FormatResult<()> { @@ -22,7 +44,14 @@ impl FormatNodeRule for FormatExprFString { f_string_quoting(item, &f.context().locator()), ) .fmt(f), - _ => in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f), + _ => match self.layout.implicit_group_id { + Some(group_id) => group(&FormatImplicitConcatenatedString::new(item)) + .with_group_id(Some(group_id)) + .fmt(f), + None => { + in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f) + } + }, } } } @@ -35,6 +64,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 +83,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..abc612c164222 100644 --- a/crates/ruff_python_formatter/src/expression/expr_string_literal.rs +++ b/crates/ruff_python_formatter/src/expression/expr_string_literal.rs @@ -1,4 +1,4 @@ -use ruff_formatter::FormatRuleWithOptions; +use ruff_formatter::{FormatRuleWithOptions, GroupId}; use ruff_python_ast::{AnyNodeRef, ExprStringLiteral, StringLike}; use crate::expression::parentheses::{ @@ -10,14 +10,34 @@ use crate::string::{FormatImplicitConcatenatedString, StringLikeExtensions}; #[derive(Default)] pub struct FormatExprStringLiteral { - kind: StringLiteralKind, + layout: ExprStringLiteralLayout, +} + +#[derive(Default)] +pub struct ExprStringLiteralLayout { + pub kind: StringLiteralKind, + /// ID of the group wrapping the implicit concatenated string. If `None`, the implicit + /// is wrapped in an [`in_parentheses_only_group`]. + /// + /// This is used when formatting implicit concatenated strings in assignment value positions + /// where the positioning of comments depends on whether the string can be joined or not. + pub implicit_group_id: Option, +} + +impl ExprStringLiteralLayout { + pub const fn docstring() -> Self { + Self { + kind: StringLiteralKind::Docstring, + implicit_group_id: None, + } + } } impl FormatRuleWithOptions> for FormatExprStringLiteral { - type Options = StringLiteralKind; + type Options = ExprStringLiteralLayout; fn with_options(mut self, options: Self::Options) -> Self { - self.kind = options; + self.layout = options; self } } @@ -27,15 +47,23 @@ impl FormatNodeRule for FormatExprStringLiteral { let ExprStringLiteral { value, .. } = item; match value.as_slice() { - [string_literal] => string_literal.format().with_options(self.kind).fmt(f), + [string_literal] => string_literal + .format() + .with_options(self.layout.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()); + assert!(!self.layout.kind.is_docstring()); - in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)) + match self.layout.implicit_group_id { + Some(group_id) => group(&FormatImplicitConcatenatedString::new(item)) + .with_group_id(Some(group_id)) + .fmt(f), + None => in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)) + .fmt(f), + } } - .fmt(f), } } } @@ -48,7 +76,7 @@ impl NeedsParentheses for ExprStringLiteral { ) -> OptionalParentheses { if self.value.is_implicit_concatenated() { OptionalParentheses::Multiline - } else if StringLike::String(self).is_multiline(context.source()) { + } else if StringLike::from(self).is_multiline(context.source()) { OptionalParentheses::Never } else { OptionalParentheses::BestFit diff --git a/crates/ruff_python_formatter/src/expression/mod.rs b/crates/ruff_python_formatter/src/expression/mod.rs index 1cc060ec11423..d96efa9b81134 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, }; @@ -768,15 +769,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/lib.rs b/crates/ruff_python_formatter/src/lib.rs index fcb512c63c16c..a145206ae51a2 100644 --- a/crates/ruff_python_formatter/src/lib.rs +++ b/crates/ruff_python_formatter/src/lib.rs @@ -160,7 +160,9 @@ mod tests { use ruff_python_trivia::CommentRanges; use ruff_text_size::{TextRange, TextSize}; - use crate::{format_module_ast, format_module_source, format_range, PyFormatOptions}; + use crate::{ + format_module_ast, format_module_source, format_range, PreviewMode, PyFormatOptions, + }; /// Very basic test intentionally kept very similar to the CLI #[test] @@ -188,13 +190,11 @@ if True: #[test] fn quick_test() { let source = r#" -def main() -> None: - if True: - some_very_long_variable_name_abcdefghijk = Foo() - some_very_long_variable_name_abcdefghijk = some_very_long_variable_name_abcdefghijk[ - some_very_long_variable_name_abcdefghijk.some_very_long_attribute_name - == "This is a very long string abcdefghijk" - ] +fstring = ( + f"We have to remember to escape {braces}." + " Like {these}." + f" But not {this}." +) "#; let source_type = PySourceType::Python; @@ -203,7 +203,8 @@ def main() -> None: let source_path = "code_inline.py"; let parsed = parse(source, source_type.as_mode()).unwrap(); let comment_ranges = CommentRanges::from(parsed.tokens()); - let options = PyFormatOptions::from_extension(Path::new(source_path)); + let options = PyFormatOptions::from_extension(Path::new(source_path)) + .with_preview(PreviewMode::Enabled); let formatted = format_module_ast(&parsed, &comment_ranges, source, options).unwrap(); // Uncomment the `dbg` to print the IR. diff --git a/crates/ruff_python_formatter/src/other/arguments.rs b/crates/ruff_python_formatter/src/other/arguments.rs index 6e50f1cf01eb1..0209e36bdb3b1 100644 --- a/crates/ruff_python_formatter/src/other/arguments.rs +++ b/crates/ruff_python_formatter/src/other/arguments.rs @@ -223,6 +223,8 @@ fn is_huggable_string_argument( arguments: &Arguments, context: &PyFormatContext, ) -> bool { + // TODO: Implicit concatenated could become regular string. Although not if it is multiline. So that should be fine? + // Double check if string.is_implicit_concatenated() || !string.is_multiline(context.source()) { return false; } diff --git a/crates/ruff_python_formatter/src/other/f_string.rs b/crates/ruff_python_formatter/src/other/f_string.rs index 9202ea94aab20..80023a9924e24 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) 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 12e653e755860..d62fea4634ccb 100644 --- a/crates/ruff_python_formatter/src/other/f_string_element.rs +++ b/crates/ruff_python_formatter/src/other/f_string_element.rs @@ -57,7 +57,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.context.flags(), true); + let normalized = + normalize_string(literal_content, 0, self.context.flags(), false, false, true); match &normalized { Cow::Borrowed(_) => source_text_slice(self.element.range()).fmt(f), Cow::Owned(normalized) => text(normalized).fmt(f), @@ -231,11 +232,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..a7aa952826072 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,27 @@ 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()); + } + return; } 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()); + } + return; } // 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(), + ); + } + return; } 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 92b86f3ccfd7b..08f31cafef9bb 100644 --- a/crates/ruff_python_formatter/src/preview.rs +++ b/crates/ruff_python_formatter/src/preview.rs @@ -60,3 +60,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/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..174fe245287c2 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_python_ast::{ - AnyNodeRef, Expr, ExprAttribute, ExprCall, Operator, StmtAssign, TypeParams, + AnyNodeRef, Expr, ExprAttribute, ExprCall, Operator, StmtAssign, StringLike, TypeParams, }; use crate::builders::parenthesize_if_expands; @@ -8,6 +8,9 @@ use crate::comments::{ trailing_comments, Comments, LeadingDanglingTrailingComments, SourceComment, }; use crate::context::{NodeLevel, WithNodeLevel}; +use crate::expression::expr_bytes_literal::ExprBytesLiteralLayout; +use crate::expression::expr_f_string::ExprFStringLayout; +use crate::expression::expr_string_literal::ExprStringLiteralLayout; use crate::expression::parentheses::{ is_expression_parenthesized, optional_parentheses, NeedsParentheses, OptionalParentheses, Parentheses, Parenthesize, @@ -16,7 +19,10 @@ use crate::expression::{ can_omit_optional_parentheses, has_own_parentheses, has_parentheses, maybe_parenthesize_expression, }; +use crate::other::string_literal::StringLiteralKind; +use crate::preview::is_join_implicit_concatenated_string_enabled; use crate::statement::trailing_semicolon; +use crate::string::StringLikeExtensions; use crate::{has_skip_comment, prelude::*}; #[derive(Default)] @@ -281,8 +287,14 @@ impl Format> for FormatStatementsLastExpression<'_> { match self { FormatStatementsLastExpression::LeftToRight { value, statement } => { let can_inline_comment = should_inline_comments(value, *statement, f.context()); - - if !can_inline_comment { + let is_implicit_concatenated = + is_join_implicit_concatenated_string_enabled(f.context()) + && StringLike::try_from(*value).is_ok_and(|string| { + string.is_implicit_concatenated() + && !string.is_implicit_and_cant_join(f.context()) + }); + + if !can_inline_comment && !is_implicit_concatenated { return maybe_parenthesize_expression( value, *statement, @@ -299,30 +311,98 @@ impl Format> for FormatStatementsLastExpression<'_> { *statement, &comments, ) { - let group_id = f.group_id("optional_parentheses"); + if is_implicit_concatenated { + let implicit_group_id = f.group_id("implicit_concatenated"); + optional_parentheses(&format_with(|f| { + inline_comments.mark_formatted(); + + match value { + Expr::StringLiteral(literal) => { + assert!(literal.value.is_implicit_concatenated()); + + literal + .format() + .with_options(ExprStringLiteralLayout { + kind: StringLiteralKind::String, + implicit_group_id: Some(implicit_group_id), + }) + .fmt(f)?; + } + Expr::BytesLiteral(literal) => { + assert!(literal.value.is_implicit_concatenated()); + + literal + .format() + .with_options(ExprBytesLiteralLayout { + implicit_group_id: Some(implicit_group_id), + }) + .fmt(f)?; + } + + Expr::FString(literal) => { + assert!(literal.value.is_implicit_concatenated()); + + literal + .format() + .with_options(ExprFStringLayout { + implicit_group_id: Some(implicit_group_id), + }) + .fmt(f)?; + } + + _ => { + unreachable!( + "Should only be called for implicit concatenated strings." + ) + } + } + + if !inline_comments.is_empty() { + // If the implicit concatenated string fits in a single line,, format the comment in parentheses + if_group_fits_on_line(&inline_comments) + .with_group_id(Some(implicit_group_id)) + .fmt(f)?; + } + + Ok(()) + })) + .fmt(f)?; + + if !inline_comments.is_empty() { + // If the implicit concatenated string expands, format the comments outside the parentheses + if_group_breaks(&inline_comments) + .with_group_id(Some(implicit_group_id)) + .fmt(f)?; + } + } else { + let group_id = f.group_id("optional_parentheses"); + let f = &mut WithNodeLevel::new(NodeLevel::Expression(Some(group_id)), f); + + best_fit_parenthesize(&format_once(|f| { + inline_comments.mark_formatted(); - let f = &mut WithNodeLevel::new(NodeLevel::Expression(Some(group_id)), f); + // Can we just call `FormatImplicitString` here with a custom layout assigns a group id + // that we can reference in `if_group_breaks` and `if_group_fits_on_line`. + // The other alternative is that this code creates the `FormatImplicitStringGroup` and by-passes + // calling `FormatStringLiteralExpression` directly. + value.format().with_options(Parentheses::Never).fmt(f)?; - best_fit_parenthesize(&format_with(|f| { - inline_comments.mark_formatted(); + 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)?; + } - value.format().with_options(Parentheses::Never).fmt(f)?; + Ok(()) + })) + .with_group_id(Some(group_id)) + .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)?; + // 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(()) - })) - .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(()) diff --git a/crates/ruff_python_formatter/src/statement/suite.rs b/crates/ruff_python_formatter/src/statement/suite.rs index c483f917e2395..29d8f5f26fd5b 100644 --- a/crates/ruff_python_formatter/src/statement/suite.rs +++ b/crates/ruff_python_formatter/src/statement/suite.rs @@ -11,7 +11,7 @@ use crate::comments::{ leading_comments, trailing_comments, Comments, LeadingDanglingTrailingComments, }; use crate::context::{NodeLevel, TopLevelStatementPosition, WithIndentLevel, WithNodeLevel}; -use crate::other::string_literal::StringLiteralKind; +use crate::expression::expr_string_literal::ExprStringLiteralLayout; use crate::prelude::*; use crate::statement::stmt_expr::FormatStmtExpr; use crate::verbatim::{ @@ -850,7 +850,7 @@ impl Format> for DocstringStmt<'_> { .then_some(source_position(self.docstring.start())), string_literal .format() - .with_options(StringLiteralKind::Docstring), + .with_options(ExprStringLiteralLayout::docstring()), f.options() .source_map_generation() .is_enabled() diff --git a/crates/ruff_python_formatter/src/string/mod.rs b/crates/ruff_python_formatter/src/string/mod.rs index 3eaf87121f459..a0852cc6b53f1 100644 --- a/crates/ruff_python_formatter/src/string/mod.rs +++ b/crates/ruff_python_formatter/src/string/mod.rs @@ -1,8 +1,11 @@ +use std::borrow::Cow; + use memchr::memchr2; pub(crate) use normalize::{normalize_string, NormalizedString, StringNormalizer}; -use ruff_formatter::format_args; +use ruff_formatter::{format_args, write}; use ruff_python_ast::str::Quote; +use ruff_python_ast::str_prefix::{ByteStringPrefix, FStringPrefix}; use ruff_python_ast::{ self as ast, str_prefix::{AnyStringPrefix, StringLiteralPrefix}, @@ -17,6 +20,8 @@ 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::preview::is_join_implicit_concatenated_string_enabled; +use crate::string::normalize::QuoteMetadata; use crate::QuoteStyle; pub(crate) mod docstring; @@ -41,6 +46,88 @@ impl<'a> FormatImplicitConcatenatedString<'a> { string: string.into(), } } + + fn merged_flags(&self, context: &PyFormatContext) -> Option { + if !is_join_implicit_concatenated_string_enabled(context) { + return None; + } + + // Early exit if it's known that this string can't be joined because it + // * isn't supported (e.g. raw strings or triple quoted strings) + // * the implicit concatenated string can never be flat because of comments + if self.string.parts().any(|part| { + // 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() { + true + } else { + let comments = context.comments().leading_dangling_trailing(&part); + + // 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. + comments.has_leading() || comments.has_trailing() + } + }) { + return None; + } + + // Don't merge multiline strings because that's pointless, a multiline string can + // never fit on a single line. + if !self.string.is_fstring() && self.string.is_multiline(context.source()) { + return None; + } + + // The string is either a regular string, f-string, or bytes string. + let normalizer = StringNormalizer::from_context(context); + + // TODO: Do we need to respect the quoting? + let mut merged_quotes: Option = None; + let mut prefix = match self.string { + StringLike::String(_) => AnyStringPrefix::Regular(StringLiteralPrefix::Empty), + StringLike::Bytes(_) => AnyStringPrefix::Bytes(ByteStringPrefix::Regular), + StringLike::FString(_) => AnyStringPrefix::Format(FStringPrefix::Regular), + }; + + // TODO unify quote styles. + // Possibly run directly on entire string? + let first_part = self.string.parts().next()?; + + // 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 Ok(preferred_quote) = Quote::try_from(normalizer.preferred_quote_style(first_part)) + else { + // TODO: Handle preserve + return None; + }; + + for part in self.string.parts() { + // Again, this takes a StringPart and not a `AnyStringPart`. + let part_quote_metadata = QuoteMetadata::from_part(part, preferred_quote, context); + + if part.flags().is_f_string() { + prefix = AnyStringPrefix::Format(FStringPrefix::Regular); + } + + if let Some(merged) = merged_quotes.as_mut() { + // FIXME: this is not correct. + *merged = part_quote_metadata.merge(merged)?; + } else { + merged_quotes = Some(part_quote_metadata); + } + } + + Some(AnyStringFlags::new( + prefix, + merged_quotes?.choose(preferred_quote), + false, + )) + } } impl Format> for FormatImplicitConcatenatedString<'_> { @@ -48,35 +135,74 @@ impl Format> for FormatImplicitConcatenatedString<'_> { 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()); + let format_expanded = format_with(|f| { + 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 + }; - 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) + 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![ + line_suffix_boundary(), + leading_comments(part_comments.leading), + format_part, + trailing_comments(part_comments.trailing) + ]); + } + + joiner.finish() + }); + + if let Some(flags) = self.merged_flags(f.context()) { + let format_flat = format_with(|f| { + let quotes = StringQuotes::from(flags); + + write!(f, [flags.prefix(), quotes])?; + + // TODO: strings in expression statements aren't joined correctly because they aren't wrap in a group :( + // TODO: FStrings when the f-string preview style is enabled??? + + for part in self.string.parts() { + let content = f.context().locator().slice(part.content_range()); + let normalized = normalize_string( + content, + 0, + flags, + flags.is_f_string() && !part.flags().is_f_string(), + true, + false, + ); + match normalized { + Cow::Borrowed(_) => source_text_slice(part.content_range()).fmt(f)?, + Cow::Owned(normalized) => text(&normalized).fmt(f)?, + } } - StringLikePart::Bytes(bytes_literal) => bytes_literal.format().fmt(f), - StringLikePart::FString(part) => FormatFString::new(part, quoting).fmt(f), + + quotes.fmt(f) }); - joiner.entry(&format_args![ - line_suffix_boundary(), - leading_comments(part_comments.leading), - format_part, - trailing_comments(part_comments.trailing) - ]); + write!( + f, + [ + if_group_fits_on_line(&format_flat), + if_group_breaks(&format_expanded) + ] + ) + } else { + format_expanded.fmt(f) } - - joiner.finish() } } @@ -147,6 +273,8 @@ pub(crate) trait StringLikeExtensions { fn quoting(&self, locator: &Locator<'_>) -> Quoting; fn is_multiline(&self, source: &str) -> bool; + + fn is_implicit_and_cant_join(&self, context: &PyFormatContext) -> bool; } impl StringLikeExtensions for ast::StringLike<'_> { @@ -159,15 +287,31 @@ 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() } } } + + fn is_implicit_and_cant_join(&self, context: &PyFormatContext) -> bool { + if !self.is_implicit_concatenated() { + return false; + } + + for part in self.parts() { + if part.flags().is_triple_quoted() || part.flags().is_raw_string() { + return true; + } + + if context.comments().leading_trailing(&part).next().is_some() { + return true; + } + } + + false + } } diff --git a/crates/ruff_python_formatter/src/string/normalize.rs b/crates/ruff_python_formatter/src/string/normalize.rs index 6836ad80828ae..658d0c25b500f 100644 --- a/crates/ruff_python_formatter/src/string/normalize.rs +++ b/crates/ruff_python_formatter/src/string/normalize.rs @@ -14,7 +14,7 @@ use crate::QuoteStyle; pub(crate) struct StringNormalizer<'a, 'src> { quoting: Quoting, - preferred_quote_style: QuoteStyle, + preferred_quote_style: Option, context: &'a PyFormatContext<'src>, } @@ -22,13 +22,13 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { pub(crate) fn from_context(context: &'a PyFormatContext<'src>) -> Self { Self { quoting: Quoting::default(), - preferred_quote_style: context.options().quote_style(), + preferred_quote_style: None, context, } } pub(crate) fn with_preferred_quote_style(mut self, quote_style: QuoteStyle) -> Self { - self.preferred_quote_style = quote_style; + self.preferred_quote_style = Some(quote_style); self } @@ -38,7 +38,9 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { } fn quoting(&self, string: StringLikePart) -> Quoting { - if let FStringState::InsideExpressionElement(context) = self.context.f_string_state() { + match (self.quoting, self.context.f_string_state()) { + (Quoting::Preserve, _) => Quoting::Preserve, + // If we're inside an f-string, we need to make sure to preserve the // existing quotes unless we're inside a triple-quoted f-string and // the inner string itself isn't triple-quoted. For example: @@ -53,32 +55,36 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { // The reason to preserve the quotes is based on the assumption that // the original f-string is valid in terms of quoting, and we don't // want to change that to make it invalid. - if (context.f_string().flags().is_triple_quoted() && !string.flags().is_triple_quoted()) - || self.context.options().target_version().supports_pep_701() - { - self.quoting - } else { - Quoting::Preserve + (Quoting::CanChange, FStringState::InsideExpressionElement(context)) => { + if (context.f_string().flags().is_triple_quoted() + && !string.flags().is_triple_quoted()) + || self.context.options().target_version().supports_pep_701() + { + Quoting::CanChange + } else { + Quoting::Preserve + } } - } else { - self.quoting + + (Quoting::CanChange, _) => Quoting::CanChange, } } - /// Computes the strings preferred quotes. - pub(crate) fn choose_quotes(&self, string: StringLikePart) -> QuoteSelection { - let raw_content = self.context.locator().slice(string.content_range()); - let first_quote_or_normalized_char_offset = raw_content - .bytes() - .position(|b| matches!(b, b'\\' | b'"' | b'\'' | b'\r' | b'{')); - let string_flags = string.flags(); - - let new_kind = match self.quoting(string) { - Quoting::Preserve => string_flags, + /// Determines the preferred quote style for `string`. + /// 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. + pub(super) fn preferred_quote_style(&self, string: StringLikePart) -> QuoteStyle { + match self.quoting(string) { + Quoting::Preserve => QuoteStyle::Preserve, Quoting::CanChange => { + let preferred_quote_style = self + .preferred_quote_style + .unwrap_or(self.context.options().quote_style()); + // Per PEP 8, always prefer double quotes for triple-quoted strings. // Except when using quote-style-preserve. - let preferred_style = if string_flags.is_triple_quoted() { + if string.flags().is_triple_quoted() { // ... unless we're formatting a code snippet inside a docstring, // then we specifically want to invert our quote style to avoid // writing out invalid Python. @@ -126,39 +132,48 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { // if it doesn't have perfect alignment with PEP8. if let Some(quote) = self.context.docstring() { QuoteStyle::from(quote.opposite()) - } else if self.preferred_quote_style.is_preserve() { + } else if preferred_quote_style.is_preserve() { QuoteStyle::Preserve } else { QuoteStyle::Double } } else { - self.preferred_quote_style - }; - - if let Ok(preferred_quote) = Quote::try_from(preferred_style) { - if let Some(first_quote_or_normalized_char_offset) = - first_quote_or_normalized_char_offset - { - if string_flags.is_raw_string() { - choose_quotes_for_raw_string( - &raw_content[first_quote_or_normalized_char_offset..], - string_flags, - preferred_quote, - ) - } else { - choose_quotes_impl( - &raw_content[first_quote_or_normalized_char_offset..], - string_flags, - preferred_quote, - ) - } - } else { - string_flags.with_quote_style(preferred_quote) - } - } else { - string_flags + preferred_quote_style } } + } + } + + /// Computes the strings preferred quotes. + pub(crate) fn choose_quotes(&self, string: StringLikePart) -> QuoteSelection { + let raw_content = self.context.locator().slice(string.content_range()); + let first_quote_or_normalized_char_offset = raw_content + .bytes() + .position(|b| matches!(b, b'\\' | b'"' | b'\'' | b'\r' | b'{')); + let string_flags = string.flags(); + let preferred_style = self.preferred_quote_style(string); + + let new_kind = match ( + Quote::try_from(preferred_style), + first_quote_or_normalized_char_offset, + ) { + // The string contains no quotes so it's safe to use the preferred quote style + (Ok(preferred_quote), None) => string_flags.with_quote_style(preferred_quote), + + // The preferred quote style is single or double quotes, and the string contains a quote or + // another character that may require escaping + (Ok(preferred_quote), Some(first_quote_or_normalized_char_offset)) => { + let quote = QuoteMetadata::from_str( + &raw_content[first_quote_or_normalized_char_offset..], + string.flags(), + preferred_quote, + ) + .choose(preferred_quote); + string_flags.with_quote_style(quote) + } + + // The preferred quote style is to preserve the quotes, so let's do that. + (Err(_), _) => string_flags, }; QuoteSelection { @@ -181,6 +196,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 { @@ -209,119 +226,128 @@ impl QuoteSelection { } } -#[derive(Debug)] -pub(crate) struct NormalizedString<'a> { - /// Holds data about the quotes and prefix of the string - flags: AnyStringFlags, - - /// The range of the string's content in the source (minus prefix and quotes). - content_range: TextRange, +#[derive(Clone, Debug)] +pub(crate) struct QuoteMetadata { + kind: QuoteMetadataKind, - /// The normalized text - text: Cow<'a, str>, + /// The quote style in the source. + source_style: Quote, } -impl<'a> NormalizedString<'a> { - pub(crate) fn text(&self) -> &Cow<'a, str> { - &self.text +/// Tracks information about the used quotes in a string which is used +/// to choose the quotes for a part. +impl QuoteMetadata { + pub(crate) fn from_part( + part: StringLikePart, + preferred_quote: Quote, + context: &PyFormatContext, + ) -> Self { + let text = context.locator().slice(part.content_range()); + + Self::from_str(text, part.flags(), preferred_quote) } - pub(crate) fn flags(&self) -> AnyStringFlags { - self.flags - } -} + pub(crate) fn from_str(text: &str, flags: AnyStringFlags, preferred_quote: Quote) -> Self { + let kind = if flags.is_raw_string() { + QuoteMetadataKind::raw(text, preferred_quote, flags.is_triple_quoted()) + } else if flags.is_triple_quoted() { + QuoteMetadataKind::triple_quoted(text, preferred_quote) + } else { + QuoteMetadataKind::regular(text) + }; -impl Ranged for NormalizedString<'_> { - fn range(&self) -> TextRange { - self.content_range + Self { + kind, + source_style: flags.quote_style(), + } } -} -impl Format> for NormalizedString<'_> { - fn fmt(&self, f: &mut Formatter>) -> FormatResult<()> { - let quotes = StringQuotes::from(self.flags); - ruff_formatter::write!(f, [self.flags.prefix(), quotes])?; - match &self.text { - Cow::Borrowed(_) => { - source_text_slice(self.range()).fmt(f)?; + pub(super) fn choose(&self, preferred_quote: Quote) -> Quote { + match self.kind { + QuoteMetadataKind::Raw { contains_preferred } => { + if contains_preferred { + self.source_style + } else { + preferred_quote + } } - Cow::Owned(normalized) => { - text(normalized).fmt(f)?; + QuoteMetadataKind::Triple { contains_preferred } => { + if contains_preferred { + self.source_style + } else { + preferred_quote + } } + QuoteMetadataKind::Regular { + single_quotes, + double_quotes, + } => match single_quotes.cmp(&double_quotes) { + Ordering::Less => Quote::Single, + Ordering::Equal => preferred_quote, + Ordering::Greater => Quote::Double, + }, + } + } + + pub(super) fn merge(self, other: &QuoteMetadata) -> Option { + match (self.kind, other.kind) { + ( + QuoteMetadataKind::Regular { + single_quotes: self_single, + double_quotes: self_double, + }, + QuoteMetadataKind::Regular { + single_quotes: other_single, + double_quotes: other_double, + }, + ) => Some(Self { + kind: QuoteMetadataKind::Regular { + single_quotes: self_single + other_single, + double_quotes: self_double + other_double, + }, + source_style: self.source_style, + }), + // Can't merge quotes from raw strings (even when both strings are raw) + (QuoteMetadataKind::Raw { .. }, _) | (_, QuoteMetadataKind::Raw { .. }) => None, + // Can't merge quotes from triple quoted strings (even when both strings are triple quoted) + (QuoteMetadataKind::Triple { .. }, _) | (_, QuoteMetadataKind::Triple { .. }) => None, } - quotes.fmt(f) } } -/// Choose the appropriate quote style for a raw string. -/// -/// The preferred quote style is chosen unless the string contains unescaped quotes of the -/// preferred style. For example, `r"foo"` is chosen over `r'foo'` if the preferred quote -/// style is double quotes. -fn choose_quotes_for_raw_string( - input: &str, - flags: AnyStringFlags, - preferred_quote: Quote, -) -> AnyStringFlags { - let preferred_quote_char = preferred_quote.as_char(); - let mut chars = input.chars().peekable(); - let contains_unescaped_configured_quotes = loop { - match chars.next() { - Some('\\') => { - // Ignore escaped characters - chars.next(); - } - // `"` or `'` - Some(c) if c == preferred_quote_char => { - if !flags.is_triple_quoted() { - break true; - } +#[derive(Copy, Clone, Debug)] +enum QuoteMetadataKind { + /// A raw string. + /// + /// For raw strings it's only possible to change the quotes if the preferred quote style + /// isn't used inside the string. + Raw { contains_preferred: bool }, - match chars.peek() { - // We can't turn `r'''\""'''` into `r"""\"""""`, this would confuse the parser - // about where the closing triple quotes start - None => break true, - Some(next) if *next == preferred_quote_char => { - // `""` or `''` - chars.next(); + /// Regular (non raw) triple quoted string. + /// + /// For triple quoted strings it's only possible to change the quotes if no + /// triple of the preferred quotes is used inside the string. + Triple { contains_preferred: bool }, - // We can't turn `r'''""'''` into `r""""""""`, nor can we have - // `"""` or `'''` respectively inside the string - if chars.peek().is_none() || chars.peek() == Some(&preferred_quote_char) { - break true; - } - } - _ => {} - } - } - Some(_) => continue, - None => break false, - } - }; - if contains_unescaped_configured_quotes { - flags - } else { - flags.with_quote_style(preferred_quote) - } + /// A single quoted string that uses either double or single quotes. + /// + /// For regular strings it's desired to pick the quote style that requires the least escaping. + /// E.g. pick single quotes for `'A "dog"'` because using single quotes would require escaping + /// the two `"`. + Regular { + single_quotes: u32, + double_quotes: u32, + }, } -/// Choose the appropriate quote style for a string. -/// -/// For single quoted strings, the preferred quote style is used, unless the alternative quote style -/// would require fewer escapes. -/// -/// For triple quoted strings, the preferred quote style is always used, unless the string contains -/// a triplet of the quote character (e.g., if double quotes are preferred, double quotes will be -/// used unless the string contains `"""`). -fn choose_quotes_impl( - input: &str, - flags: AnyStringFlags, - preferred_quote: Quote, -) -> AnyStringFlags { - let quote = if flags.is_triple_quoted() { +impl QuoteMetadataKind { + /// For triple quoted strings, the preferred quote style can't be used if the string contains + /// a tripled of the quote character (e.g., if double quotes are preferred, double quotes will be + /// used unless the string contains `"""`). + fn triple_quoted(content: &str, preferred_quote: Quote) -> Self { // True if the string contains a triple quote sequence of the configured quote style. let mut uses_triple_quotes = false; - let mut chars = input.chars().peekable(); + let mut chars = content.chars().peekable(); while let Some(c) = chars.next() { let preferred_quote_char = preferred_quote.as_char(); @@ -369,18 +395,18 @@ fn choose_quotes_impl( } } - if uses_triple_quotes { - // String contains a triple quote sequence of the configured quote style. - // Keep the existing quote style. - flags.quote_style() - } else { - preferred_quote + Self::Triple { + contains_preferred: uses_triple_quotes, } - } else { + } + + /// For single quoted strings, the preferred quote style is used, unless the alternative quote style + /// would require fewer escapes. + fn regular(text: &str) -> Self { let mut single_quotes = 0u32; let mut double_quotes = 0u32; - for c in input.chars() { + for c in text.chars() { match c { '\'' => { single_quotes += 1; @@ -394,25 +420,107 @@ fn choose_quotes_impl( } } - match single_quotes.cmp(&double_quotes) { - Ordering::Less => Quote::Single, - Ordering::Equal => preferred_quote, - Ordering::Greater => Quote::Double, + Self::Regular { + single_quotes, + double_quotes, } - }; + } + + /// Computes if a raw string uses the preferred quote. If it does, then it's not possible + /// to change the quote style because it would require escaping which isn't possible in raw strings. + fn raw(text: &str, preferred: Quote, triple_quoted: bool) -> Self { + let mut chars = text.chars().peekable(); + let preferred_quote_char = preferred.as_char(); + + let contains_unescaped_configured_quotes = loop { + match chars.next() { + Some('\\') => { + // Ignore escaped characters + chars.next(); + } + // `"` or `'` + Some(c) if c == preferred_quote_char => { + if !triple_quoted { + break true; + } + + match chars.peek() { + // We can't turn `r'''\""'''` into `r"""\"""""`, this would confuse the parser + // about where the closing triple quotes start + None => break true, + Some(next) if *next == preferred_quote_char => { + // `""` or `''` + chars.next(); + + // We can't turn `r'''""'''` into `r""""""""`, nor can we have + // `"""` or `'''` respectively inside the string + if chars.peek().is_none() || chars.peek() == Some(&preferred_quote_char) + { + break true; + } + } + _ => {} + } + } + Some(_) => continue, + None => break false, + } + }; - flags.with_quote_style(quote) + Self::Raw { + contains_preferred: contains_unescaped_configured_quotes, + } + } +} + +#[derive(Debug)] +pub(crate) struct NormalizedString<'a> { + /// Holds data about the quotes and prefix of the string + flags: AnyStringFlags, + + /// The range of the string's content in the source (minus prefix and quotes). + content_range: TextRange, + + /// The normalized text + text: Cow<'a, str>, +} + +impl<'a> NormalizedString<'a> { + pub(crate) fn text(&self) -> &Cow<'a, str> { + &self.text + } + + pub(crate) fn flags(&self) -> AnyStringFlags { + self.flags + } +} + +impl Ranged for NormalizedString<'_> { + fn range(&self) -> TextRange { + self.content_range + } +} + +impl Format> for NormalizedString<'_> { + fn fmt(&self, f: &mut Formatter>) -> FormatResult<()> { + let quotes = StringQuotes::from(self.flags); + ruff_formatter::write!(f, [self.flags.prefix(), quotes])?; + match &self.text { + Cow::Borrowed(_) => source_text_slice(self.range()).fmt(f)?, + Cow::Owned(normalized) => text(normalized).fmt(f)?, + } + + quotes.fmt(f) + } } -/// Adds the necessary quote escapes and removes unnecessary escape sequences when quoting `input` -/// with the provided [`StringQuotes`] style. -/// -/// Returns the normalized string and whether it contains new lines. pub(crate) fn normalize_string( input: &str, start_offset: usize, - flags: AnyStringFlags, - format_fstring: bool, + 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. // `output` must remain empty if `input` is already normalized. @@ -421,29 +529,39 @@ pub(crate) fn normalize_string( // If `last_index` is `0` at the end, then the input is already normalized and can be returned as is. let mut last_index = 0; - let quote = flags.quote_style(); + let quote = new_flags.quote_style(); let preferred_quote = quote.as_char(); let opposite_quote = quote.opposite().as_char(); let mut chars = CharIndicesWithOffset::new(input, start_offset).peekable(); - let is_raw = flags.is_raw_string(); - let is_fstring = !format_fstring && flags.is_f_string(); + let is_raw = new_flags.is_raw_string(); + + let is_fstring = !format_f_string && new_flags.is_f_string(); let mut formatted_value_nesting = 0u32; while let Some((index, c)) = chars.next() { - if is_fstring && matches!(c, '{' | '}') { - 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 matches!(c, '{' | '}') { + 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' { output.push_str(&input[last_index..index]); @@ -466,8 +584,10 @@ pub(crate) fn normalize_string( } else { // Length of the `\` plus the length of the escape sequence character (`u` | `U` | `x`) let escape_start_len = '\\'.len_utf8() + next.len_utf8(); - if let Some(normalised) = UnicodeEscape::new(next, !flags.is_byte_string()) - .and_then(|escape| escape.normalize(&input[index + escape_start_len..])) + if let Some(normalised) = + UnicodeEscape::new(next, !new_flags.is_byte_string()).and_then( + |escape| escape.normalize(&input[index + escape_start_len..]), + ) { let escape_start_offset = index + escape_start_len; if let Cow::Owned(normalised) = &normalised { @@ -485,7 +605,7 @@ pub(crate) fn normalize_string( } } - if !flags.is_triple_quoted() { + if !new_flags.is_triple_quoted() { #[allow(clippy::if_same_then_else)] if next == opposite_quote && formatted_value_nesting == 0 { // Remove the escape by ending before the backslash and starting again with the quote @@ -498,7 +618,7 @@ pub(crate) fn normalize_string( } } } - } else if !flags.is_triple_quoted() + } else if !new_flags.is_triple_quoted() && c == preferred_quote && formatted_value_nesting == 0 { @@ -507,18 +627,24 @@ 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(); } } } - let normalized = if last_index == 0 { + if last_index == 0 { Cow::Borrowed(input) } else { output.push_str(&input[last_index..]); Cow::Owned(output) - }; - - normalized + } } #[derive(Clone, Debug)] @@ -671,14 +797,15 @@ impl UnicodeEscape { mod tests { use std::borrow::Cow; + use super::UnicodeEscape; + use crate::string::normalize_string; + use ruff_python_ast::str_prefix::FStringPrefix; use ruff_python_ast::{ str::Quote, str_prefix::{AnyStringPrefix, ByteStringPrefix}, AnyStringFlags, }; - use super::{normalize_string, UnicodeEscape}; - #[test] fn normalize_32_escape() { let escape_sequence = UnicodeEscape::new('U', true).unwrap(); @@ -701,9 +828,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 b2cfc4dd6b08c..061d7d0397eac 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.iter() { + 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: Lazy = Lazy::new(|| { Regex::new( diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__composition.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__composition.py.snap index c1c42e11bbb3d..c3c5fcec28efc 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__composition.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__composition.py.snap @@ -610,5 +610,3 @@ class C: } ) ``` - - 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__expression.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__expression.py.snap index 5b60337c94cda..ef4d76704a625 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__expression.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__expression.py.snap @@ -1028,5 +1028,3 @@ bbbb >> bbbb * bbbb last_call() # standalone comment at ENDMARKER ``` - - 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 eeda12f088cf7..0797f41e7d25f 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 54b2c0f438b71..a2d10e4d7020d 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@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..d863d66ef2af0 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,21 @@ b"Unicode Escape sequence don't apply to bytes: \N{0x} \u{ABCD} \U{ABCDEFGH}" ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -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 +456,16 @@ b"Unicode Escape sequence don't apply to bytes: \N{0x} \u{ABCD} \U{ABCDEFGH}" ``` - +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -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 5faebb836e37d..fc1c0807851b4 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 @@ -340,7 +340,7 @@ source_type = Python ``` ```python -(f"{one}" f"{two}") +(f"{one}{two}") rf"Not-so-tricky \"quote" @@ -380,7 +380,7 @@ result_f = ( ) ( - f"{1}" f"{2}" # comment 3 + f"{1}{2}" # comment 3 ) ( @@ -1004,6 +1004,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 = ( @@ -1022,6 +1028,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,64 +67,72 @@ x = f"{a}" x = f"{ 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..dc6bf1367f5f2 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,22 @@ a = """\\\x1f""" ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -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 +531,17 @@ a = """\\\x1f""" ``` - +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -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@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 + + +```