From 2f88f849726d50de91efc48ac0148395b7231cd0 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Wed, 23 Oct 2024 07:57:53 +0200 Subject: [PATCH] Alternate quotes for strings inside f-strings in preview (#13860) --- crates/ruff_dev/src/format_dev.rs | 5 +- crates/ruff_python_ast/src/expression.rs | 4 + .../test/fixtures/ruff/expression/fstring.py | 35 +- .../fixtures/ruff/expression/fstring_py312.py | 11 + .../src/other/f_string.rs | 37 +- .../src/other/f_string_element.rs | 18 +- crates/ruff_python_formatter/src/preview.rs | 2 + .../src/string/normalize.rs | 333 +++++++++++++++--- ...bility@cases__preview_long_strings.py.snap | 8 +- ...__preview_long_strings__regression.py.snap | 8 +- .../format@expression__fstring.py.snap | 170 +++++++-- .../format@expression__fstring_py312.py.snap | 43 ++- 12 files changed, 556 insertions(+), 118 deletions(-) diff --git a/crates/ruff_dev/src/format_dev.rs b/crates/ruff_dev/src/format_dev.rs index 2ef361a46d69b..f9be6b0bf591a 100644 --- a/crates/ruff_dev/src/format_dev.rs +++ b/crates/ruff_dev/src/format_dev.rs @@ -251,8 +251,7 @@ pub(crate) fn main(args: &Args) -> anyhow::Result { } info!( parent: None, - "Done: {} stability errors, {} files, similarity index {:.5}), files with differences: {} took {:.2}s, {} input files contained syntax errors ", - error_count, + "Done: {error_count} stability/syntax errors, {} files, similarity index {:.5}), files with differences: {} took {:.2}s, {} input files contained syntax errors ", result.file_count, result.statistics.similarity_index(), result.statistics.files_with_differences, @@ -796,7 +795,7 @@ impl CheckFileError { | CheckFileError::PrintError(_) | CheckFileError::Panic { .. } => false, #[cfg(not(debug_assertions))] - CheckFileError::Slow(_) => false, + CheckFileError::Slow(_) => true, } } } diff --git a/crates/ruff_python_ast/src/expression.rs b/crates/ruff_python_ast/src/expression.rs index a4a9c044ba08c..6fb0ef9d29306 100644 --- a/crates/ruff_python_ast/src/expression.rs +++ b/crates/ruff_python_ast/src/expression.rs @@ -524,6 +524,10 @@ impl StringLikePart<'_> { self.end() - kind.closer_len(), ) } + + pub const fn is_fstring(self) -> bool { + matches!(self, Self::FString(_)) + } } impl<'a> From<&'a ast::StringLiteral> for StringLikePart<'a> { 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 69f65c20c573a..3c895ee85aeb4 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 @@ -94,6 +94,8 @@ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" +x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb' = } ccccccccccccccc" # Multiple larger expressions which exceeds the line length limit. Here, we need to decide # whether to split at the first or second expression. This should work similarly to the @@ -144,18 +146,37 @@ {'aaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccccccccc'} }" +############################################################################################# # Quotes +############################################################################################# f"foo 'bar' {x}" f"foo \"bar\" {x}" f'foo "bar" {x}' f'foo \'bar\' {x}' f"foo {"bar"}" -f"foo {'\'bar\''}" + +f"single quoted '{x}' double quoted \"{x}\"" # Same number of quotes => use preferred quote style +f"single quote ' {x} double quoted \"{x}\"" # More double quotes => use single quotes +f"single quoted '{x}' double quote \" {x}" # More single quotes => use double quotes + +fr"single quotes ' {x}" # Keep double because `'` can't be escaped +fr'double quotes " {x}' # Keep single because `"` can't be escaped +fr'flip quotes {x}' # Use preferred quotes, because raw string contains now quotes. # Here, the formatter will remove the escapes which is correct because they aren't allowed # pre 3.12. This means we can assume that the f-string is used in the context of 3.12. +f"foo {'\'bar\''}" f"foo {'\"bar\"'}" +# Quotes inside the expressions have no impact on the quote selection of the outer string. +# Required so that the following two examples result in the same formatting. +f'foo {10 + len("bar")}' +f"foo {10 + len('bar')}" + +# Pre 312, preserve the outer quotes if the f-string contains quotes in the debug expression +f'foo {10 + len("bar")=}' +f'''foo {10 + len('''bar''')=}''' +f'''foo {10 + len('bar')=}''' # Fine to change the quotes because it uses triple quotes # Triple-quoted strings # It's ok to use the same quote char for the inner string if it's single-quoted. @@ -164,6 +185,16 @@ # But if the inner string is also triple-quoted then we should preserve the existing quotes. f"""test {'''inner'''}""" +# It's not okay to change the quote style if the inner string is triple quoted and contains a quote. +f'{"""other " """}' +f'{"""other " """ + "more"}' +f'{b"""other " """}' +f'{f"""other " """}' + +# Not valid Pre 3.12 +f"""test {f'inner {'''inner inner'''}'}""" +f"""test {f'''inner {"""inner inner"""}'''}""" + # Magic trailing comma # # The expression formatting will result in breaking it across multiple lines with a @@ -312,6 +343,6 @@ # 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 string uses double quotes in an expression {"it's a quote"}' f'This f-string does not use any quotes.' ) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring_py312.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring_py312.py index 8ce24fa363574..bd1d755f2d5fb 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring_py312.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring_py312.py @@ -4,3 +4,14 @@ # Quotes reuse f"{'a'}" + +# 312+, it's okay to change the outer quotes even when there's a debug expression using the same quotes +f'foo {10 + len("bar")=}' +f'''foo {10 + len("""bar""")=}''' + +# 312+, it's okay to change the quotes here without creating an invalid f-string +f'{"""other " """}' +f'{"""other " """ + "more"}' +f'{b"""other " """}' +f'{f"""other " """}' + diff --git a/crates/ruff_python_formatter/src/other/f_string.rs b/crates/ruff_python_formatter/src/other/f_string.rs index 9202ea94aab20..826d5dbf67caf 100644 --- a/crates/ruff_python_formatter/src/other/f_string.rs +++ b/crates/ruff_python_formatter/src/other/f_string.rs @@ -1,12 +1,12 @@ +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, 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 ruff_text_size::Ranged; use super::f_string_element::FormatFStringElement; @@ -35,7 +35,7 @@ impl Format> for FormatFString<'_> { // 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) + Quoting::CanChange } else { self.quoting }; @@ -92,17 +92,21 @@ impl Format> for FormatFString<'_> { #[derive(Clone, Copy, Debug)] pub(crate) struct FStringContext { - flags: AnyStringFlags, + /// The string flags of the enclosing f-string part. + enclosing_flags: AnyStringFlags, layout: FStringLayout, } impl FStringContext { const fn new(flags: AnyStringFlags, layout: FStringLayout) -> Self { - Self { flags, layout } + Self { + enclosing_flags: flags, + layout, + } } pub(crate) fn flags(self) -> AnyStringFlags { - self.flags + self.enclosing_flags } pub(crate) const fn layout(self) -> FStringLayout { @@ -149,20 +153,3 @@ 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_element.rs b/crates/ruff_python_formatter/src/other/f_string_element.rs index 12e653e755860..b47a0fa220084 100644 --- a/crates/ruff_python_formatter/src/other/f_string_element.rs +++ b/crates/ruff_python_formatter/src/other/f_string_element.rs @@ -2,8 +2,8 @@ use std::borrow::Cow; use ruff_formatter::{format_args, write, Buffer, RemoveSoftLinesBuffer}; use ruff_python_ast::{ - ConversionFlag, Expr, FStringElement, FStringExpressionElement, FStringLiteralElement, - StringFlags, + AnyStringFlags, ConversionFlag, Expr, FStringElement, FStringExpressionElement, + FStringLiteralElement, StringFlags, }; use ruff_text_size::Ranged; @@ -33,7 +33,7 @@ impl Format> for FormatFStringElement<'_> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { match self.element { FStringElement::Literal(string_literal) => { - FormatFStringLiteralElement::new(string_literal, self.context).fmt(f) + FormatFStringLiteralElement::new(string_literal, self.context.flags()).fmt(f) } FStringElement::Expression(expression) => { FormatFStringExpressionElement::new(expression, self.context).fmt(f) @@ -45,19 +45,23 @@ impl Format> for FormatFStringElement<'_> { /// Formats an f-string literal element. pub(crate) struct FormatFStringLiteralElement<'a> { element: &'a FStringLiteralElement, - context: FStringContext, + /// Flags of the enclosing F-string part + fstring_flags: AnyStringFlags, } impl<'a> FormatFStringLiteralElement<'a> { - pub(crate) fn new(element: &'a FStringLiteralElement, context: FStringContext) -> Self { - Self { element, context } + pub(crate) fn new(element: &'a FStringLiteralElement, fstring_flags: AnyStringFlags) -> Self { + Self { + element, + fstring_flags, + } } } 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.fstring_flags, true); match &normalized { Cow::Borrowed(_) => source_text_slice(self.element.range()).fmt(f), Cow::Owned(normalized) => text(normalized).fmt(f), diff --git a/crates/ruff_python_formatter/src/preview.rs b/crates/ruff_python_formatter/src/preview.rs index 92b86f3ccfd7b..bacb9d203f02a 100644 --- a/crates/ruff_python_formatter/src/preview.rs +++ b/crates/ruff_python_formatter/src/preview.rs @@ -15,11 +15,13 @@ pub(crate) const fn is_hug_parens_with_braces_and_square_brackets_enabled( } /// Returns `true` if the [`f-string formatting`](https://github.com/astral-sh/ruff/issues/7594) preview style is enabled. +/// WARNING: This preview style depends on `is_f_string_implicit_concatenated_string_literal_quotes_enabled`. pub(crate) fn is_f_string_formatting_enabled(context: &PyFormatContext) -> bool { context.is_preview() } /// See [#13539](https://github.com/astral-sh/ruff/pull/13539) +/// Remove `Quoting` when stabalizing this preview style. pub(crate) fn is_f_string_implicit_concatenated_string_literal_quotes_enabled( context: &PyFormatContext, ) -> bool { diff --git a/crates/ruff_python_formatter/src/string/normalize.rs b/crates/ruff_python_formatter/src/string/normalize.rs index 5e5706a38f769..c4913091464f3 100644 --- a/crates/ruff_python_formatter/src/string/normalize.rs +++ b/crates/ruff_python_formatter/src/string/normalize.rs @@ -3,7 +3,10 @@ use std::cmp::Ordering; use std::iter::FusedIterator; use ruff_formatter::FormatContext; -use ruff_python_ast::{str::Quote, AnyStringFlags, StringFlags, StringLikePart}; +use ruff_python_ast::visitor::source_order::SourceOrderVisitor; +use ruff_python_ast::{ + str::Quote, AnyStringFlags, BytesLiteral, FString, StringFlags, StringLikePart, StringLiteral, +}; use ruff_text_size::{Ranged, TextRange}; use crate::context::FStringState; @@ -37,53 +40,55 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { self } - fn quoting(&self, string: StringLikePart) -> Quoting { - 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: - // - // ```python - // f"""outer {"inner"}""" # Valid - // f"""outer {"""inner"""}""" # Invalid - // ``` - // - // Or, if the target version supports PEP 701. - // - // 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. - (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 - } - } - - (Quoting::CanChange, _) => Quoting::CanChange, - } - } - /// 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) { + match self.quoting { Quoting::Preserve => QuoteStyle::Preserve, Quoting::CanChange => { let preferred_quote_style = self .preferred_quote_style .unwrap_or(self.context.options().quote_style()); + if preferred_quote_style.is_preserve() { + return QuoteStyle::Preserve; + } + + // There are two cases where it's necessary to preserve the quotes + // if the target version is pre 3.12 and the part is an f-string. + if !self.context.options().target_version().supports_pep_701() { + if let StringLikePart::FString(fstring) = string { + // An f-string expression contains a debug text with a quote character + // because the formatter will emit the debug expression **exactly** the same as in the source text. + if is_fstring_with_quoted_debug_expression(fstring, self.context) { + return QuoteStyle::Preserve; + } + + // An f-string expression that contains a triple quoted string literal expression + // that contains a quote. + if is_fstring_with_triple_quoted_literal_expression_containing_quotes( + fstring, + self.context, + ) { + return QuoteStyle::Preserve; + } + } + } + + // For f-strings prefer alternating the quotes unless The outer string is triple quoted and the inner isn't. + if let FStringState::InsideExpressionElement(parent_context) = + self.context.f_string_state() + { + let parent_flags = parent_context.f_string().flags(); + + if !parent_flags.is_triple_quoted() || string.flags().is_triple_quoted() { + return QuoteStyle::from(parent_flags.quote_style().opposite()); + } + } + // Per PEP 8, always prefer double quotes for triple-quoted strings. - // Except when using quote-style-preserve. 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 @@ -132,8 +137,6 @@ 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 preferred_quote_style.is_preserve() { - QuoteStyle::Preserve } else { QuoteStyle::Double } @@ -163,12 +166,18 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { // 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); + let metadata = if string.is_fstring() { + QuoteMetadata::from_part(string, self.context, preferred_quote) + } else { + QuoteMetadata::from_str( + &raw_content[first_quote_or_normalized_char_offset..], + string.flags(), + preferred_quote, + ) + }; + + let quote = metadata.choose(preferred_quote); + string_flags.with_quote_style(quote) } @@ -235,6 +244,59 @@ pub(crate) struct QuoteMetadata { /// 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, + context: &PyFormatContext, + preferred_quote: Quote, + ) -> Self { + match part { + StringLikePart::String(_) | StringLikePart::Bytes(_) => { + let text = context.locator().slice(part.content_range()); + + Self::from_str(text, part.flags(), preferred_quote) + } + StringLikePart::FString(fstring) => { + if is_f_string_formatting_enabled(context) { + // For f-strings, only consider the quotes inside string-literals but ignore + // quotes inside expressions. This allows both the outer and the nested literals + // to make the optimal local-choice to reduce the total number of quotes necessary. + // This doesn't require any pre 312 special handling because an expression + // can never contain the outer quote character, not even escaped: + // ```python + // f"{'escaping a quote like this \" is a syntax error pre 312'}" + // ``` + let mut literals = fstring.elements.literals(); + + let Some(first) = literals.next() else { + return QuoteMetadata::from_str("", part.flags(), preferred_quote); + }; + + let mut metadata = QuoteMetadata::from_str( + context.locator().slice(first.range()), + fstring.flags.into(), + preferred_quote, + ); + + for literal in literals { + metadata = metadata + .merge(&QuoteMetadata::from_str( + context.locator().slice(literal.range()), + fstring.flags.into(), + preferred_quote, + )) + .expect("Merge to succeed because all parts have the same flags"); + } + + metadata + } else { + let text = context.locator().slice(part.content_range()); + + Self::from_str(text, part.flags(), preferred_quote) + } + } + } + } + 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()) @@ -276,6 +338,61 @@ impl QuoteMetadata { }, } } + + /// Merges the quotes metadata of different literals. + /// + /// ## Raw and triple quoted strings + /// Merging raw and triple quoted strings is only correct if all literals are from the same part. + /// E.g. it's okay to merge triple and raw strings from a single `FString` part's literals + /// but it isn't safe to merge raw and triple quoted strings from different parts of an implicit + /// concatenated string. Where safe means, it may lead to incorrect results. + pub(super) fn merge(self, other: &QuoteMetadata) -> Option { + let kind = 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, + }, + ) => QuoteMetadataKind::Regular { + single_quotes: self_single + other_single, + double_quotes: self_double + other_double, + }, + + // Can't merge quotes from raw strings (even when both strings are raw) + ( + QuoteMetadataKind::Raw { + contains_preferred: self_contains_preferred, + }, + QuoteMetadataKind::Raw { + contains_preferred: other_contains_preferred, + }, + ) => QuoteMetadataKind::Raw { + contains_preferred: self_contains_preferred || other_contains_preferred, + }, + + ( + QuoteMetadataKind::Triple { + contains_preferred: self_contains_preferred, + }, + QuoteMetadataKind::Triple { + contains_preferred: other_contains_preferred, + }, + ) => QuoteMetadataKind::Triple { + contains_preferred: self_contains_preferred || other_contains_preferred, + }, + + (_, _) => return None, + }; + + Some(Self { + kind, + source_style: self.source_style, + }) + } } #[derive(Copy, Clone, Debug)] @@ -738,18 +855,142 @@ impl UnicodeEscape { } } +/// Returns `true` if `string` is an f-string part that contains a debug expression that uses quotes +/// and the format target is pre Python 312 +/// We can't join f-strings where: +/// +/// ```python +/// f"{10 + len('bar')=}" +/// f'{10 + len("bar")=}' +/// f""""{10 + len('''bar''')=}""" +/// ``` +pub(super) fn is_fstring_with_quoted_debug_expression( + fstring: &FString, + context: &PyFormatContext, +) -> bool { + if fstring.elements.expressions().any(|expression| { + if expression.debug_text.is_some() { + let content = context.locator().slice(expression.range()); + match fstring.flags.quote_style() { + Quote::Single => { + if fstring.flags.is_triple_quoted() { + content.contains(r#"""""#) + } else { + content.contains('"') + } + } + Quote::Double => { + if fstring.flags.is_triple_quoted() { + content.contains("'''") + } else { + content.contains('\'') + } + } + } + } else { + false + } + }) { + return true; + } + + false +} + +/// Tests if the `fstring` contains any triple quoted string, byte, or f-string literal that +/// contains a quote character opposite to its own quote character. +/// +/// ```python +/// f'{"""other " """}' +/// ``` +/// +/// We can't flip the quote of the outer f-string because it would result in invalid syntax: +/// ```python +/// f"{'''other " '''}' +/// ``` +pub(super) fn is_fstring_with_triple_quoted_literal_expression_containing_quotes( + fstring: &FString, + context: &PyFormatContext, +) -> bool { + struct Visitor<'a> { + context: &'a PyFormatContext<'a>, + found: bool, + } + + impl Visitor<'_> { + fn visit_string_like_part(&mut self, part: StringLikePart) { + if !part.flags().is_triple_quoted() || self.found { + return; + } + + let contains_quotes = match part { + StringLikePart::String(_) | StringLikePart::Bytes(_) => { + self.contains_quote(part.content_range(), part.flags()) + } + StringLikePart::FString(fstring) => { + let mut contains_quotes = false; + for literal in fstring.elements.literals() { + if self.contains_quote(literal.range(), fstring.flags.into()) { + contains_quotes = true; + break; + } + } + + contains_quotes + } + }; + + if contains_quotes { + self.found = true; + } + } + + fn contains_quote(&self, range: TextRange, flags: AnyStringFlags) -> bool { + self.context + .locator() + .slice(range) + .contains(flags.quote_style().as_char()) + } + } + + impl SourceOrderVisitor<'_> for Visitor<'_> { + fn visit_f_string(&mut self, f_string: &FString) { + self.visit_string_like_part(StringLikePart::FString(f_string)); + } + + fn visit_string_literal(&mut self, string_literal: &StringLiteral) { + self.visit_string_like_part(StringLikePart::String(string_literal)); + } + + fn visit_bytes_literal(&mut self, bytes_literal: &BytesLiteral) { + self.visit_string_like_part(StringLikePart::Bytes(bytes_literal)); + } + } + + let mut visitor = Visitor { + context, + found: false, + }; + + ruff_python_ast::visitor::source_order::walk_f_string(&mut visitor, fstring); + + visitor.found +} + #[cfg(test)] mod tests { use std::borrow::Cow; - use super::UnicodeEscape; - use crate::string::normalize_string; use ruff_python_ast::{ str::Quote, str_prefix::{AnyStringPrefix, ByteStringPrefix}, AnyStringFlags, }; + use crate::string::normalize_string; + + use super::UnicodeEscape; + #[test] fn normalize_32_escape() { let escape_sequence = UnicodeEscape::new('U', true).unwrap(); 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..a4c9bc8300142 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 @@ -941,7 +941,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share log.info( - "Skipping:" - f" {desc['db_id']} {foo('bar',x=123)} {'foo' != 'bar'} {(x := 'abc=')} {pos_share=} {desc['status']} {desc['exposure_max']}" -+ f'Skipping: {desc["db_id"]} {foo("bar", x=123)} {"foo" != "bar"} {(x := "abc=")} {pos_share=} {desc["status"]} {desc["exposure_max"]}' ++ f"Skipping: {desc['db_id']} {foo('bar', x=123)} {'foo' != 'bar'} {(x := 'abc=')} {pos_share=} {desc['status']} {desc['exposure_max']}" ) log.info( @@ -965,7 +965,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share log.info( - "Skipping:" - f" {'a' == 'b' == 'c' == 'd'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}" -+ f'Skipping: {"a" == "b" == "c" == "d"} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' ++ f"Skipping: {'a' == 'b' == 'c' == 'd'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}" ) log.info( @@ -1406,7 +1406,7 @@ log.info( ) log.info( - f'Skipping: {desc["db_id"]} {foo("bar", x=123)} {"foo" != "bar"} {(x := "abc=")} {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f"Skipping: {desc['db_id']} {foo('bar', x=123)} {'foo' != 'bar'} {(x := 'abc=')} {pos_share=} {desc['status']} {desc['exposure_max']}" ) log.info( @@ -1422,7 +1422,7 @@ log.info( ) log.info( - f'Skipping: {"a" == "b" == "c" == "d"} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f"Skipping: {'a' == 'b' == 'c' == 'd'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}" ) log.info( 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..e017e31abfe26 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 @@ -1201,10 +1201,10 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: + "With single quote: ' " + f" {my_dict['foo']}" + ' With double quote: " ' -+ f' {my_dict["bar"]}' ++ f" {my_dict['bar']}" ) + -+s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:\'{my_dict["foo"]}\'' ++s = f"Lorem Ipsum is simply dummy text of the printing and typesetting industry:'{my_dict['foo']}'" ``` ## Ruff Output @@ -1839,10 +1839,10 @@ s = ( "With single quote: ' " f" {my_dict['foo']}" ' With double quote: " ' - f' {my_dict["bar"]}' + f" {my_dict['bar']}" ) -s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:\'{my_dict["foo"]}\'' +s = f"Lorem Ipsum is simply dummy text of the printing and typesetting industry:'{my_dict['foo']}'" ``` ## Black Output 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..1496a5e98cdc5 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 @@ -100,6 +100,8 @@ x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 8 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" +x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb' = } ccccccccccccccc" # Multiple larger expressions which exceeds the line length limit. Here, we need to decide # whether to split at the first or second expression. This should work similarly to the @@ -150,18 +152,37 @@ xxxxxxx = f"{ {'aaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccccccccc'} }" +############################################################################################# # Quotes +############################################################################################# f"foo 'bar' {x}" f"foo \"bar\" {x}" f'foo "bar" {x}' f'foo \'bar\' {x}' f"foo {"bar"}" -f"foo {'\'bar\''}" + +f"single quoted '{x}' double quoted \"{x}\"" # Same number of quotes => use preferred quote style +f"single quote ' {x} double quoted \"{x}\"" # More double quotes => use single quotes +f"single quoted '{x}' double quote \" {x}" # More single quotes => use double quotes + +fr"single quotes ' {x}" # Keep double because `'` can't be escaped +fr'double quotes " {x}' # Keep single because `"` can't be escaped +fr'flip quotes {x}' # Use preferred quotes, because raw string contains now quotes. # Here, the formatter will remove the escapes which is correct because they aren't allowed # pre 3.12. This means we can assume that the f-string is used in the context of 3.12. +f"foo {'\'bar\''}" f"foo {'\"bar\"'}" +# Quotes inside the expressions have no impact on the quote selection of the outer string. +# Required so that the following two examples result in the same formatting. +f'foo {10 + len("bar")}' +f"foo {10 + len('bar')}" + +# Pre 312, preserve the outer quotes if the f-string contains quotes in the debug expression +f'foo {10 + len("bar")=}' +f'''foo {10 + len('''bar''')=}''' +f'''foo {10 + len('bar')=}''' # Fine to change the quotes because it uses triple quotes # Triple-quoted strings # It's ok to use the same quote char for the inner string if it's single-quoted. @@ -170,6 +191,16 @@ f"""test {"inner"}""" # But if the inner string is also triple-quoted then we should preserve the existing quotes. f"""test {'''inner'''}""" +# It's not okay to change the quote style if the inner string is triple quoted and contains a quote. +f'{"""other " """}' +f'{"""other " """ + "more"}' +f'{b"""other " """}' +f'{f"""other " """}' + +# Not valid Pre 3.12 +f"""test {f'inner {'''inner inner'''}'}""" +f"""test {f'''inner {"""inner inner"""}'''}""" + # Magic trailing comma # # The expression formatting will result in breaking it across multiple lines with a @@ -318,7 +349,7 @@ 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 string uses double quotes in an expression {"it's a quote"}' f'This f-string does not use any quotes.' ) ``` @@ -429,13 +460,15 @@ aaaaaaaaaaa = ( # This should never add the optional parentheses because even after adding them, the # f-string exceeds the line length limit. -x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa {"bbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} ccccccccccccccc" +x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa {'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb'} ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 8 - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb' } ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" +x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb' = } ccccccccccccccc" # Multiple larger expressions which exceeds the line length limit. Here, we need to decide # whether to split at the first or second expression. This should work similarly to the @@ -496,18 +529,37 @@ xxxxxxx = f"{ } }" +############################################################################################# # Quotes +############################################################################################# f"foo 'bar' {x}" f'foo "bar" {x}' f'foo "bar" {x}' f"foo 'bar' {x}" -f"foo {"bar"}" -f"foo {'\'bar\''}" +f"foo {'bar'}" + +f"single quoted '{x}' double quoted \"{x}\"" # Same number of quotes => use preferred quote style +f'single quote \' {x} double quoted "{x}"' # More double quotes => use single quotes +f"single quoted '{x}' double quote \" {x}" # More single quotes => use double quotes + +rf"single quotes ' {x}" # Keep double because `'` can't be escaped +rf'double quotes " {x}' # Keep single because `"` can't be escaped +rf"flip quotes {x}" # Use preferred quotes, because raw string contains now quotes. # Here, the formatter will remove the escapes which is correct because they aren't allowed # pre 3.12. This means we can assume that the f-string is used in the context of 3.12. +f"foo {"'bar'"}" f"foo {'"bar"'}" +# Quotes inside the expressions have no impact on the quote selection of the outer string. +# Required so that the following two examples result in the same formatting. +f"foo {10 + len('bar')}" +f"foo {10 + len('bar')}" + +# Pre 312, preserve the outer quotes if the f-string contains quotes in the debug expression +f'foo {10 + len("bar")=}' +f"""foo {10 + len('''bar''')=}""" +f"""foo {10 + len('bar')=}""" # Fine to change the quotes because it uses triple quotes # Triple-quoted strings # It's ok to use the same quote char for the inner string if it's single-quoted. @@ -516,6 +568,16 @@ f"""test {"inner"}""" # But if the inner string is also triple-quoted then we should preserve the existing quotes. f"""test {'''inner'''}""" +# It's not okay to change the quote style if the inner string is triple quoted and contains a quote. +f'{"""other " """}' +f'{"""other " """ + "more"}' +f'{b"""other " """}' +f'{f"""other " """}' + +# Not valid Pre 3.12 +f"""test {f"inner {'''inner inner'''}"}""" +f"""test {f'''inner {"""inner inner"""}'''}""" + # Magic trailing comma # # The expression formatting will result in breaking it across multiple lines with a @@ -662,7 +724,7 @@ 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 string uses double quotes in an expression {"it's a quote"}" f"This f-string does not use any quotes." ) ``` @@ -778,6 +840,8 @@ x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 8 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" +x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb' = } ccccccccccccccc" # Multiple larger expressions which exceeds the line length limit. Here, we need to decide # whether to split at the first or second expression. This should work similarly to the @@ -828,18 +892,37 @@ xxxxxxx = f"{ {'aaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccccccccc'} }" +############################################################################################# # Quotes +############################################################################################# f"foo 'bar' {x}" f'foo "bar" {x}' f'foo "bar" {x}' f"foo 'bar' {x}" f"foo {"bar"}" -f"foo {'\'bar\''}" + +f"single quoted '{x}' double quoted \"{x}\"" # Same number of quotes => use preferred quote style +f'single quote \' {x} double quoted "{x}"' # More double quotes => use single quotes +f"single quoted '{x}' double quote \" {x}" # More single quotes => use double quotes + +rf"single quotes ' {x}" # Keep double because `'` can't be escaped +rf'double quotes " {x}' # Keep single because `"` can't be escaped +rf"flip quotes {x}" # Use preferred quotes, because raw string contains now quotes. # Here, the formatter will remove the escapes which is correct because they aren't allowed # pre 3.12. This means we can assume that the f-string is used in the context of 3.12. +f"foo {'\'bar\''}" f"foo {'\"bar\"'}" +# Quotes inside the expressions have no impact on the quote selection of the outer string. +# Required so that the following two examples result in the same formatting. +f'foo {10 + len("bar")}' +f"foo {10 + len('bar')}" + +# Pre 312, preserve the outer quotes if the f-string contains quotes in the debug expression +f'foo {10 + len("bar")=}' +f'''foo {10 + len('''bar''')=}''' +f"""foo {10 + len('bar')=}""" # Fine to change the quotes because it uses triple quotes # Triple-quoted strings # It's ok to use the same quote char for the inner string if it's single-quoted. @@ -848,6 +931,16 @@ f"""test {"inner"}""" # But if the inner string is also triple-quoted then we should preserve the existing quotes. f"""test {'''inner'''}""" +# It's not okay to change the quote style if the inner string is triple quoted and contains a quote. +f'{"""other " """}' +f'{"""other " """ + "more"}' +f'{b"""other " """}' +f'{f"""other " """}' + +# Not valid Pre 3.12 +f"""test {f'inner {'''inner inner'''}'}""" +f"""test {f'''inner {"""inner inner"""}'''}""" + # Magic trailing comma # # The expression formatting will result in breaking it across multiple lines with a @@ -994,7 +1087,7 @@ 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 string uses double quotes in an expression {"it's a quote"}' f'This f-string does not use any quotes.' ) ``` @@ -1022,7 +1115,7 @@ _ = ( " f()\n" # XXX: The following line changes depending on whether the tests # are run through the interactive interpreter or with -m -@@ -67,64 +67,72 @@ +@@ -67,29 +67,31 @@ x = f"{a}" x = f"{ a = }" @@ -1052,16 +1145,17 @@ _ = ( # This should never add the optional parentheses because even after adding them, the # f-string exceeds the line length limit. -x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ccccccccccccccc" -+x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa {"bbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} ccccccccccccccc" ++x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa {'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb'} ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" -x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 8 - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ccccccccccccccc" +x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 8 -+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ++ 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb' +} ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" - + x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 +@@ -98,35 +100,41 @@ # Multiple larger expressions which exceeds the line length limit. Here, we need to decide # whether to split at the first or second expression. This should work similarly to the # assignment statement formatting where we split from right to left in preview mode. @@ -1119,7 +1213,7 @@ _ = ( x = f"{ # comment 13 {'x': 1, 'y': 2} = }" # But, if there's a format specifier or a conversion flag then we don't need to add -@@ -139,7 +147,11 @@ +@@ -141,7 +149,11 @@ }" # And, split the expression itself because it exceeds the line length. xxxxxxx = f"{ @@ -1131,14 +1225,36 @@ _ = ( + } }" - # Quotes -@@ -152,13 +164,13 @@ + ############################################################################################# +@@ -151,7 +163,7 @@ + f'foo "bar" {x}' + f'foo "bar" {x}' + f"foo 'bar' {x}" +-f"foo {"bar"}" ++f"foo {'bar'}" + + f"single quoted '{x}' double quoted \"{x}\"" # Same number of quotes => use preferred quote style + f'single quote \' {x} double quoted "{x}"' # More double quotes => use single quotes +@@ -163,23 +175,23 @@ # Here, the formatter will remove the escapes which is correct because they aren't allowed # pre 3.12. This means we can assume that the f-string is used in the context of 3.12. +-f"foo {'\'bar\''}" -f"foo {'\"bar\"'}" ++f"foo {"'bar'"}" +f"foo {'"bar"'}" + # Quotes inside the expressions have no impact on the quote selection of the outer string. + # Required so that the following two examples result in the same formatting. +-f'foo {10 + len("bar")}' + f"foo {10 + len('bar')}" ++f"foo {10 + len('bar')}" + + # Pre 312, preserve the outer quotes if the f-string contains quotes in the debug expression + f'foo {10 + len("bar")=}' +-f'''foo {10 + len('''bar''')=}''' ++f"""foo {10 + len('''bar''')=}""" + f"""foo {10 + len('bar')=}""" # Fine to change the quotes because it uses triple quotes # Triple-quoted strings # It's ok to use the same quote char for the inner string if it's single-quoted. @@ -1148,7 +1264,16 @@ _ = ( # But if the inner string is also triple-quoted then we should preserve the existing quotes. f"""test {'''inner'''}""" -@@ -171,63 +183,66 @@ +@@ -190,7 +202,7 @@ + f'{f"""other " """}' + + # Not valid Pre 3.12 +-f"""test {f'inner {'''inner inner'''}'}""" ++f"""test {f"inner {'''inner inner'''}"}""" + f"""test {f'''inner {"""inner inner"""}'''}""" + + # Magic trailing comma +@@ -202,63 +214,66 @@ f"aaaaaaa {['aaaaaaaaaaaaaaa', 'bbbbbbbbbbbbb', 'ccccccccccccccccc', 'ddddddddddddddd', 'eeeeeeeeeeeeee']} aaaaaaa" # And, if the trailing comma is already present, we still need to remove it. @@ -1243,7 +1368,7 @@ _ = ( # comment} cccccccccc""" # Conversion flags -@@ -235,24 +250,21 @@ +@@ -266,24 +281,21 @@ # This is not a valid Python code because of the additional whitespace between the `!` # and conversion type. But, our parser isn't strict about this. This should probably be # removed once we have a strict parser. @@ -1275,7 +1400,7 @@ _ = ( x = f""" { # comment 22 -@@ -261,19 +273,19 @@ +@@ -292,19 +304,19 @@ # Here, the debug expression is in a nested f-string so we should start preserving # whitespaces from that point onwards. This means we should format the outer f-string. @@ -1303,7 +1428,7 @@ _ = ( # comment 27 # comment 28 } woah {x}" -@@ -287,27 +299,27 @@ +@@ -318,27 +330,27 @@ if indent2: foo = f"""hello world hello { @@ -1343,9 +1468,10 @@ _ = ( # 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 string uses double quotes in an expression {"it's a quote"}' - f'This f-string does not use any quotes.' ++ "This string should change its quotes to double quotes" ++ f"This string uses double quotes in an expression {"it's a quote"}" + f"This f-string does not use any quotes." ) ``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring_py312.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring_py312.py.snap index fd22501c96a68..75b2a13bdc59e 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring_py312.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring_py312.py.snap @@ -10,6 +10,17 @@ input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression # Quotes reuse f"{'a'}" + +# 312+, it's okay to change the outer quotes even when there's a debug expression using the same quotes +f'foo {10 + len("bar")=}' +f'''foo {10 + len("""bar""")=}''' + +# 312+, it's okay to change the quotes here without creating an invalid f-string +f'{"""other " """}' +f'{"""other " """ + "more"}' +f'{b"""other " """}' +f'{f"""other " """}' + ``` ## Outputs @@ -35,6 +46,16 @@ source_type = Python # Quotes reuse f"{'a'}" + +# 312+, it's okay to change the outer quotes even when there's a debug expression using the same quotes +f'foo {10 + len("bar")=}' +f'''foo {10 + len("""bar""")=}''' + +# 312+, it's okay to change the quotes here without creating an invalid f-string +f'{"""other " """}' +f'{"""other " """ + "more"}' +f'{b"""other " """}' +f'{f"""other " """}' ``` @@ -42,10 +63,22 @@ f"{'a'}" ```diff --- Stable +++ Preview -@@ -3,4 +3,4 @@ - # version isn't set. +@@ -6,11 +6,11 @@ + f"{'a'}" + + # 312+, it's okay to change the outer quotes even when there's a debug expression using the same quotes +-f'foo {10 + len("bar")=}' +-f'''foo {10 + len("""bar""")=}''' ++f"foo {10 + len("bar")=}" ++f"""foo {10 + len("""bar""")=}""" - # Quotes reuse --f"{'a'}" -+f"{"a"}" + # 312+, it's okay to change the quotes here without creating an invalid f-string +-f'{"""other " """}' +-f'{"""other " """ + "more"}' +-f'{b"""other " """}' +-f'{f"""other " """}' ++f"{'''other " '''}" ++f"{'''other " ''' + 'more'}" ++f"{b'''other " '''}" ++f"{f'''other " '''}" ```