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..aded2ec2a4a73 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,10 @@ ] } -------- """ + + +# 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"}' +) 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 0421ea2a0ac78..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,6 +1,7 @@ use ruff_python_ast::FStringPart; use crate::other::f_string::FormatFString; +use crate::other::string_literal::StringLiteralKind; use crate::prelude::*; use crate::string::Quoting; @@ -24,7 +25,13 @@ impl<'a> FormatFStringPart<'a> { impl Format> for FormatFStringPart<'_> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { match self.part { - FStringPart::Literal(string_literal) => string_literal.format().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 b1fbe18df33ad..4f882f6ada09d 100644 --- a/crates/ruff_python_formatter/src/other/string_literal.rs +++ b/crates/ruff_python_formatter/src/other/string_literal.rs @@ -2,7 +2,8 @@ use ruff_formatter::FormatRuleWithOptions; use ruff_python_ast::StringLiteral; use crate::prelude::*; -use crate::string::{docstring, StringNormalizer}; +use crate::preview::is_f_string_implicit_concatenated_string_literal_quotes_enabled; +use crate::string::{docstring, Quoting, StringNormalizer}; use crate::QuoteStyle; #[derive(Default)] @@ -27,6 +28,12 @@ pub enum StringLiteralKind { String, /// A string literal used as a docstring. Docstring, + /// 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), } impl StringLiteralKind { @@ -34,6 +41,23 @@ impl StringLiteralKind { pub(crate) const fn is_docstring(self) -> bool { matches!(self, StringLiteralKind::Docstring) } + + /// Returns the quoting to be used for this string literal. + fn quoting(self, context: &PyFormatContext) -> Quoting { + match self { + StringLiteralKind::String | StringLiteralKind::Docstring => Quoting::CanChange, + #[allow(deprecated)] + StringLiteralKind::InImplicitlyConcatenatedFString(quoting) => { + // TODO: Remove StringLiteralKind::InImplicitlyConcatenatedFString when promoting + // this style to stable + if is_f_string_implicit_concatenated_string_literal_quotes_enabled(context) { + Quoting::CanChange + } else { + quoting + } + } + } + } } impl FormatNodeRule for FormatStringLiteral { @@ -48,6 +72,7 @@ impl FormatNodeRule for FormatStringLiteral { }; let normalized = StringNormalizer::from_context(f.context()) + .with_quoting(self.layout.quoting(f.context())) .with_preferred_quote_style(quote_style) .normalize(item.into()); 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/string/any.rs b/crates/ruff_python_formatter/src/string/any.rs index 58f66ca2af731..0341715ac09f6 100644 --- a/crates/ruff_python_formatter/src/string/any.rs +++ b/crates/ruff_python_formatter/src/string/any.rs @@ -11,6 +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::StringLiteralKind; use crate::prelude::*; use crate::string::Quoting; @@ -148,15 +149,20 @@ impl<'a> Iterator for AnyStringPartsIter<'a> { let part = match self { Self::String(inner) => { let part = inner.next()?; - AnyStringPart::String(part) + AnyStringPart::String { + part, + layout: StringLiteralKind::String, + } } Self::Bytes(inner) => AnyStringPart::Bytes(inner.next()?), Self::FString(inner, quoting) => { let part = inner.next()?; match part { - ast::FStringPart::Literal(string_literal) => { - AnyStringPart::String(string_literal) - } + ast::FStringPart::Literal(string_literal) => AnyStringPart::String { + part: string_literal, + #[allow(deprecated)] + layout: StringLiteralKind::InImplicitlyConcatenatedFString(*quoting), + }, ast::FStringPart::FString(f_string) => AnyStringPart::FString { part: f_string, quoting: *quoting, @@ -177,7 +183,10 @@ impl FusedIterator for AnyStringPartsIter<'_> {} /// This is constructed from the [`AnyString::parts`] method on [`AnyString`]. #[derive(Clone, Debug)] pub(super) enum AnyStringPart<'a> { - String(&'a ast::StringLiteral), + String { + part: &'a ast::StringLiteral, + layout: StringLiteralKind, + }, Bytes(&'a ast::BytesLiteral), FString { part: &'a ast::FString, @@ -188,7 +197,7 @@ pub(super) enum AnyStringPart<'a> { impl AnyStringPart<'_> { fn flags(&self) -> AnyStringFlags { match self { - Self::String(part) => part.flags.into(), + Self::String { part, .. } => part.flags.into(), Self::Bytes(bytes_literal) => bytes_literal.flags.into(), Self::FString { part, .. } => part.flags.into(), } @@ -198,7 +207,7 @@ impl AnyStringPart<'_> { impl<'a> From<&AnyStringPart<'a>> for AnyNodeRef<'a> { fn from(value: &AnyStringPart<'a>) -> Self { match value { - AnyStringPart::String(part) => AnyNodeRef::StringLiteral(part), + AnyStringPart::String { part, .. } => AnyNodeRef::StringLiteral(part), AnyStringPart::Bytes(part) => AnyNodeRef::BytesLiteral(part), AnyStringPart::FString { part, .. } => AnyNodeRef::FString(part), } @@ -208,7 +217,7 @@ impl<'a> From<&AnyStringPart<'a>> for AnyNodeRef<'a> { impl Ranged for AnyStringPart<'_> { fn range(&self) -> TextRange { match self { - Self::String(part) => part.range(), + Self::String { part, .. } => part.range(), Self::Bytes(part) => part.range(), Self::FString { part, .. } => part.range(), } @@ -218,7 +227,7 @@ impl Ranged for AnyStringPart<'_> { impl Format> for AnyStringPart<'_> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { match self { - AnyStringPart::String(part) => part.format().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..4c91a8640f54a 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,13 @@ 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"}' +) ``` ## Outputs @@ -649,6 +656,13 @@ 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"}' +) ``` @@ -973,6 +987,13 @@ 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"}' +) ``` @@ -1279,7 +1300,7 @@ hello { # comment 27 # comment 28 } woah {x}" -@@ -287,19 +299,19 @@ +@@ -287,26 +299,26 @@ if indent2: foo = f"""hello world hello { @@ -1314,4 +1335,12 @@ 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"}' + ) ```