diff --git a/crates/ruff_python_formatter/generate.py b/crates/ruff_python_formatter/generate.py index bf89ac1a4b523..ff28a04331dc0 100755 --- a/crates/ruff_python_formatter/generate.py +++ b/crates/ruff_python_formatter/generate.py @@ -33,14 +33,15 @@ def rustfmt(code: str) -> str: nodes = [] for node_line in node_lines: node = node_line.split("(")[1].split(")")[0].split("::")[-1].split("<")[0] - # `FString` and `StringLiteral` has a custom implementation while the formatting for - # `FStringLiteralElement` and `FStringExpressionElement` are handled by the `FString` + # `FString` has a custom implementation while the formatting for + # `FStringLiteralElement`, `FStringFormatSpec` and `FStringExpressionElement` are handled by the `FString` # implementation. if node in ( "FString", - "StringLiteral", "FStringLiteralElement", "FStringExpressionElement", + "FStringFormatSpec", + "Identifier", ): continue nodes.append(node) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py index 001a554163b32..69f65c20c573a 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py @@ -307,3 +307,11 @@ ] } -------- """ + + +# Implicit concatenated f-string containing quotes +_ = ( + 'This string should change its quotes to double quotes' + f'This string uses double quotes in an expression {"woah"}' + f'This f-string does not use any quotes.' +) 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 460c1519dd6da..23e8eab633eb5 100644 --- a/crates/ruff_python_formatter/src/expression/expr_string_literal.rs +++ b/crates/ruff_python_formatter/src/expression/expr_string_literal.rs @@ -4,37 +4,17 @@ use ruff_python_ast::{AnyNodeRef, ExprStringLiteral}; use crate::expression::parentheses::{ in_parentheses_only_group, NeedsParentheses, OptionalParentheses, }; -use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind}; +use crate::other::string_literal::StringLiteralKind; use crate::prelude::*; use crate::string::{AnyString, FormatImplicitConcatenatedString}; #[derive(Default)] pub struct FormatExprStringLiteral { - kind: ExprStringLiteralKind, -} - -#[derive(Default, Copy, Clone, Debug)] -pub enum ExprStringLiteralKind { - #[default] - String, - Docstring, -} - -impl ExprStringLiteralKind { - const fn string_literal_kind(self) -> StringLiteralKind { - match self { - ExprStringLiteralKind::String => StringLiteralKind::String, - ExprStringLiteralKind::Docstring => StringLiteralKind::Docstring, - } - } - - const fn is_docstring(self) -> bool { - matches!(self, ExprStringLiteralKind::Docstring) - } + kind: StringLiteralKind, } impl FormatRuleWithOptions> for FormatExprStringLiteral { - type Options = ExprStringLiteralKind; + type Options = StringLiteralKind; fn with_options(mut self, options: Self::Options) -> Self { self.kind = options; @@ -47,9 +27,7 @@ impl FormatNodeRule for FormatExprStringLiteral { let ExprStringLiteral { value, .. } = item; match value.as_slice() { - [string_literal] => { - FormatStringLiteral::new(string_literal, self.kind.string_literal_kind()).fmt(f) - } + [string_literal] => string_literal.format().with_options(self.kind).fmt(f), _ => { // This is just a sanity check because [`DocstringStmt::try_from_statement`] // ensures that the docstring is a *single* string literal. diff --git a/crates/ruff_python_formatter/src/generated.rs b/crates/ruff_python_formatter/src/generated.rs index 63667ac5095dc..90ea7b00dd51e 100644 --- a/crates/ruff_python_formatter/src/generated.rs +++ b/crates/ruff_python_formatter/src/generated.rs @@ -2935,6 +2935,42 @@ impl<'ast> IntoFormat> for ast::TypeParamParamSpec { } } +impl FormatRule> + for crate::other::string_literal::FormatStringLiteral +{ + #[inline] + fn fmt(&self, node: &ast::StringLiteral, f: &mut PyFormatter) -> FormatResult<()> { + FormatNodeRule::::fmt(self, node, f) + } +} +impl<'ast> AsFormat> for ast::StringLiteral { + type Format<'a> = FormatRefWithRule< + 'a, + ast::StringLiteral, + crate::other::string_literal::FormatStringLiteral, + PyFormatContext<'ast>, + >; + fn format(&self) -> Self::Format<'_> { + FormatRefWithRule::new( + self, + crate::other::string_literal::FormatStringLiteral::default(), + ) + } +} +impl<'ast> IntoFormat> for ast::StringLiteral { + type Format = FormatOwnedWithRule< + ast::StringLiteral, + crate::other::string_literal::FormatStringLiteral, + PyFormatContext<'ast>, + >; + fn into_format(self) -> Self::Format { + FormatOwnedWithRule::new( + self, + crate::other::string_literal::FormatStringLiteral::default(), + ) + } +} + impl FormatRule> for crate::other::bytes_literal::FormatBytesLiteral { diff --git a/crates/ruff_python_formatter/src/other/f_string.rs b/crates/ruff_python_formatter/src/other/f_string.rs index 2b2e1f0449c60..6f46176a27888 100644 --- a/crates/ruff_python_formatter/src/other/f_string.rs +++ b/crates/ruff_python_formatter/src/other/f_string.rs @@ -1,10 +1,12 @@ +use crate::prelude::*; +use crate::preview::{ + is_f_string_formatting_enabled, is_f_string_implicit_concatenated_string_literal_quotes_enabled, +}; +use crate::string::{Quoting, StringNormalizer, StringQuotes}; use ruff_formatter::write; use ruff_python_ast::{AnyStringFlags, FString, StringFlags}; use ruff_source_file::Locator; - -use crate::prelude::*; -use crate::preview::is_f_string_formatting_enabled; -use crate::string::{Quoting, StringNormalizer, StringQuotes}; +use ruff_text_size::Ranged; use super::f_string_element::FormatFStringElement; @@ -29,8 +31,17 @@ impl Format> for FormatFString<'_> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { let locator = f.context().locator(); + // If the preview style is enabled, make the decision on what quotes to use locally for each + // f-string instead of globally for the entire f-string expression. + let quoting = + if is_f_string_implicit_concatenated_string_literal_quotes_enabled(f.context()) { + f_string_quoting(self.value, &locator) + } else { + self.quoting + }; + let normalizer = StringNormalizer::from_context(f.context()) - .with_quoting(self.quoting) + .with_quoting(quoting) .with_preferred_quote_style(f.options().quote_style()); // If f-string formatting is disabled (not in preview), then we will @@ -140,3 +151,20 @@ impl FStringLayout { matches!(self, FStringLayout::Multiline) } } + +fn f_string_quoting(f_string: &FString, locator: &Locator) -> Quoting { + let triple_quoted = f_string.flags.is_triple_quoted(); + + if f_string.elements.expressions().any(|expression| { + let string_content = locator.slice(expression.range()); + if triple_quoted { + string_content.contains(r#"""""#) || string_content.contains("'''") + } else { + string_content.contains(['"', '\'']) + } + }) { + Quoting::Preserve + } else { + Quoting::CanChange + } +} diff --git a/crates/ruff_python_formatter/src/other/f_string_part.rs b/crates/ruff_python_formatter/src/other/f_string_part.rs index c471b5fc8cd4f..d33148aaccc44 100644 --- a/crates/ruff_python_formatter/src/other/f_string_part.rs +++ b/crates/ruff_python_formatter/src/other/f_string_part.rs @@ -1,7 +1,7 @@ use ruff_python_ast::FStringPart; use crate::other::f_string::FormatFString; -use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind}; +use crate::other::string_literal::StringLiteralKind; use crate::prelude::*; use crate::string::Quoting; @@ -25,14 +25,13 @@ impl<'a> FormatFStringPart<'a> { impl Format> for FormatFStringPart<'_> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { match self.part { - FStringPart::Literal(string_literal) => FormatStringLiteral::new( - string_literal, - // If an f-string part is a string literal, the f-string is always - // implicitly concatenated e.g., `"foo" f"bar {x}"`. A standalone - // string literal would be a string expression, not an f-string. - StringLiteralKind::InImplicitlyConcatenatedFString(self.quoting), - ) - .fmt(f), + #[allow(deprecated)] + FStringPart::Literal(string_literal) => string_literal + .format() + .with_options(StringLiteralKind::InImplicitlyConcatenatedFString( + self.quoting, + )) + .fmt(f), FStringPart::FString(f_string) => FormatFString::new(f_string, self.quoting).fmt(f), } } diff --git a/crates/ruff_python_formatter/src/other/string_literal.rs b/crates/ruff_python_formatter/src/other/string_literal.rs index 2d3d752d434b8..7aee127d4b06e 100644 --- a/crates/ruff_python_formatter/src/other/string_literal.rs +++ b/crates/ruff_python_formatter/src/other/string_literal.rs @@ -1,23 +1,28 @@ +use ruff_formatter::FormatRuleWithOptions; use ruff_python_ast::StringLiteral; use crate::prelude::*; +use crate::preview::is_f_string_implicit_concatenated_string_literal_quotes_enabled; use crate::string::{docstring, Quoting, StringNormalizer}; use crate::QuoteStyle; -pub(crate) struct FormatStringLiteral<'a> { - value: &'a StringLiteral, +#[derive(Default)] +pub struct FormatStringLiteral { layout: StringLiteralKind, } -impl<'a> FormatStringLiteral<'a> { - pub(crate) fn new(value: &'a StringLiteral, layout: StringLiteralKind) -> Self { - Self { value, layout } +impl FormatRuleWithOptions> for FormatStringLiteral { + type Options = StringLiteralKind; + + fn with_options(mut self, layout: StringLiteralKind) -> Self { + self.layout = layout; + self } } /// The kind of a string literal. #[derive(Copy, Clone, Debug, Default)] -pub(crate) enum StringLiteralKind { +pub enum StringLiteralKind { /// A normal string literal e.g., `"foo"`. #[default] String, @@ -26,6 +31,8 @@ pub(crate) enum StringLiteralKind { /// A string literal that is implicitly concatenated with an f-string. This /// makes the overall expression an f-string whose quoting detection comes /// from the parent node (f-string expression). + #[deprecated] + #[allow(private_interfaces)] InImplicitlyConcatenatedFString(Quoting), } @@ -36,16 +43,28 @@ impl StringLiteralKind { } /// Returns the quoting to be used for this string literal. - fn quoting(self) -> Quoting { + fn quoting(self, context: &PyFormatContext) -> Quoting { match self { StringLiteralKind::String | StringLiteralKind::Docstring => Quoting::CanChange, - StringLiteralKind::InImplicitlyConcatenatedFString(quoting) => quoting, + #[allow(deprecated)] + StringLiteralKind::InImplicitlyConcatenatedFString(quoting) => { + // Allow string literals to pick the "optimal" quote character + // even if any other fstring in the implicit concatenation uses an expression + // containing a quote character. + // TODO: Remove StringLiteralKind::InImplicitlyConcatenatedFString when promoting + // this style to stable and remove the layout from `AnyStringPart::String`. + if is_f_string_implicit_concatenated_string_literal_quotes_enabled(context) { + Quoting::CanChange + } else { + quoting + } + } } } } -impl Format> for FormatStringLiteral<'_> { - fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { +impl FormatNodeRule for FormatStringLiteral { + fn fmt_fields(&self, item: &StringLiteral, f: &mut PyFormatter) -> FormatResult<()> { let quote_style = f.options().quote_style(); let quote_style = if self.layout.is_docstring() && !quote_style.is_preserve() { // Per PEP 8 and PEP 257, always prefer double quotes for docstrings, @@ -56,9 +75,9 @@ impl Format> for FormatStringLiteral<'_> { }; let normalized = StringNormalizer::from_context(f.context()) - .with_quoting(self.layout.quoting()) + .with_quoting(self.layout.quoting(f.context())) .with_preferred_quote_style(quote_style) - .normalize(self.value.into()); + .normalize(item.into()); if self.layout.is_docstring() { docstring::format(&normalized, f) diff --git a/crates/ruff_python_formatter/src/preview.rs b/crates/ruff_python_formatter/src/preview.rs index 30d7b858dfdef..92b86f3ccfd7b 100644 --- a/crates/ruff_python_formatter/src/preview.rs +++ b/crates/ruff_python_formatter/src/preview.rs @@ -19,6 +19,13 @@ pub(crate) fn is_f_string_formatting_enabled(context: &PyFormatContext) -> bool context.is_preview() } +/// See [#13539](https://github.com/astral-sh/ruff/pull/13539) +pub(crate) fn is_f_string_implicit_concatenated_string_literal_quotes_enabled( + context: &PyFormatContext, +) -> bool { + context.is_preview() +} + pub(crate) fn is_with_single_item_pre_39_enabled(context: &PyFormatContext) -> bool { context.is_preview() } diff --git a/crates/ruff_python_formatter/src/statement/suite.rs b/crates/ruff_python_formatter/src/statement/suite.rs index d0d89839ccf73..c483f917e2395 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::expression::expr_string_literal::ExprStringLiteralKind; +use crate::other::string_literal::StringLiteralKind; 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(ExprStringLiteralKind::Docstring), + .with_options(StringLiteralKind::Docstring), f.options() .source_map_generation() .is_enabled() diff --git a/crates/ruff_python_formatter/src/string/any.rs b/crates/ruff_python_formatter/src/string/any.rs index b86b3b4fc03de..0341715ac09f6 100644 --- a/crates/ruff_python_formatter/src/string/any.rs +++ b/crates/ruff_python_formatter/src/string/any.rs @@ -11,7 +11,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::expression::expr_f_string::f_string_quoting; use crate::other::f_string::FormatFString; -use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind}; +use crate::other::string_literal::StringLiteralKind; use crate::prelude::*; use crate::string::Quoting; @@ -160,6 +160,7 @@ impl<'a> Iterator for AnyStringPartsIter<'a> { match part { ast::FStringPart::Literal(string_literal) => AnyStringPart::String { part: string_literal, + #[allow(deprecated)] layout: StringLiteralKind::InImplicitlyConcatenatedFString(*quoting), }, ast::FStringPart::FString(f_string) => AnyStringPart::FString { @@ -226,9 +227,7 @@ impl Ranged for AnyStringPart<'_> { impl Format> for AnyStringPart<'_> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { match self { - AnyStringPart::String { part, layout } => { - FormatStringLiteral::new(part, *layout).fmt(f) - } + AnyStringPart::String { part, layout } => part.format().with_options(*layout).fmt(f), AnyStringPart::Bytes(bytes_literal) => bytes_literal.format().fmt(f), AnyStringPart::FString { part, quoting } => FormatFString::new(part, *quoting).fmt(f), } 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 189d25ce3f5b3..5faebb836e37d 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 @@ -313,6 +313,14 @@ hello { ] } -------- """ + + +# Implicit concatenated f-string containing quotes +_ = ( + 'This string should change its quotes to double quotes' + f'This string uses double quotes in an expression {"woah"}' + f'This f-string does not use any quotes.' +) ``` ## Outputs @@ -649,6 +657,14 @@ hello { ] } -------- """ + + +# Implicit concatenated f-string containing quotes +_ = ( + "This string should change its quotes to double quotes" + f'This string uses double quotes in an expression {"woah"}' + f"This f-string does not use any quotes." +) ``` @@ -973,6 +989,14 @@ hello { ] } -------- """ + + +# Implicit concatenated f-string containing quotes +_ = ( + 'This string should change its quotes to double quotes' + f'This string uses double quotes in an expression {"woah"}' + f'This f-string does not use any quotes.' +) ``` @@ -1279,7 +1303,7 @@ hello { # comment 27 # comment 28 } woah {x}" -@@ -287,19 +299,19 @@ +@@ -287,27 +299,27 @@ if indent2: foo = f"""hello world hello { @@ -1314,4 +1338,14 @@ hello { + ] + } -------- """ + + + # Implicit concatenated f-string containing quotes + _ = ( +- 'This string should change its quotes to double quotes' ++ "This string should change its quotes to double quotes" + f'This string uses double quotes in an expression {"woah"}' +- f'This f-string does not use any quotes.' ++ f"This f-string does not use any quotes." + ) ```