Normalize implicit concatenated f-string quotes per part (#13539)

This commit is contained in:
Micha Reiser 2024-10-08 11:59:17 +02:00 committed by GitHub
parent 42fcbef876
commit fc661e193a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 171 additions and 62 deletions

View file

@ -33,14 +33,15 @@ node_lines = (
nodes = [] nodes = []
for node_line in node_lines: for node_line in node_lines:
node = node_line.split("(")[1].split(")")[0].split("::")[-1].split("<")[0] node = node_line.split("(")[1].split(")")[0].split("::")[-1].split("<")[0]
# `FString` and `StringLiteral` has a custom implementation while the formatting for # `FString` has a custom implementation while the formatting for
# `FStringLiteralElement` and `FStringExpressionElement` are handled by the `FString` # `FStringLiteralElement`, `FStringFormatSpec` and `FStringExpressionElement` are handled by the `FString`
# implementation. # implementation.
if node in ( if node in (
"FString", "FString",
"StringLiteral",
"FStringLiteralElement", "FStringLiteralElement",
"FStringExpressionElement", "FStringExpressionElement",
"FStringFormatSpec",
"Identifier",
): ):
continue continue
nodes.append(node) nodes.append(node)

View file

@ -307,3 +307,11 @@ 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.'
)

View file

@ -4,37 +4,17 @@ use ruff_python_ast::{AnyNodeRef, ExprStringLiteral};
use crate::expression::parentheses::{ use crate::expression::parentheses::{
in_parentheses_only_group, NeedsParentheses, OptionalParentheses, in_parentheses_only_group, NeedsParentheses, OptionalParentheses,
}; };
use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind}; use crate::other::string_literal::StringLiteralKind;
use crate::prelude::*; use crate::prelude::*;
use crate::string::{AnyString, FormatImplicitConcatenatedString}; use crate::string::{AnyString, FormatImplicitConcatenatedString};
#[derive(Default)] #[derive(Default)]
pub struct FormatExprStringLiteral { pub struct FormatExprStringLiteral {
kind: ExprStringLiteralKind, kind: StringLiteralKind,
}
#[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)
}
} }
impl FormatRuleWithOptions<ExprStringLiteral, PyFormatContext<'_>> for FormatExprStringLiteral { impl FormatRuleWithOptions<ExprStringLiteral, PyFormatContext<'_>> for FormatExprStringLiteral {
type Options = ExprStringLiteralKind; type Options = StringLiteralKind;
fn with_options(mut self, options: Self::Options) -> Self { fn with_options(mut self, options: Self::Options) -> Self {
self.kind = options; self.kind = options;
@ -47,9 +27,7 @@ impl FormatNodeRule<ExprStringLiteral> for FormatExprStringLiteral {
let ExprStringLiteral { value, .. } = item; let ExprStringLiteral { value, .. } = item;
match value.as_slice() { match value.as_slice() {
[string_literal] => { [string_literal] => string_literal.format().with_options(self.kind).fmt(f),
FormatStringLiteral::new(string_literal, self.kind.string_literal_kind()).fmt(f)
}
_ => { _ => {
// This is just a sanity check because [`DocstringStmt::try_from_statement`] // This is just a sanity check because [`DocstringStmt::try_from_statement`]
// ensures that the docstring is a *single* string literal. // ensures that the docstring is a *single* string literal.

View file

@ -2935,6 +2935,42 @@ impl<'ast> IntoFormat<PyFormatContext<'ast>> for ast::TypeParamParamSpec {
} }
} }
impl FormatRule<ast::StringLiteral, PyFormatContext<'_>>
for crate::other::string_literal::FormatStringLiteral
{
#[inline]
fn fmt(&self, node: &ast::StringLiteral, f: &mut PyFormatter) -> FormatResult<()> {
FormatNodeRule::<ast::StringLiteral>::fmt(self, node, f)
}
}
impl<'ast> AsFormat<PyFormatContext<'ast>> 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<PyFormatContext<'ast>> 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<ast::BytesLiteral, PyFormatContext<'_>> impl FormatRule<ast::BytesLiteral, PyFormatContext<'_>>
for crate::other::bytes_literal::FormatBytesLiteral for crate::other::bytes_literal::FormatBytesLiteral
{ {

View file

@ -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_formatter::write;
use ruff_python_ast::{AnyStringFlags, FString, StringFlags}; use ruff_python_ast::{AnyStringFlags, FString, StringFlags};
use ruff_source_file::Locator; use ruff_source_file::Locator;
use ruff_text_size::Ranged;
use crate::prelude::*;
use crate::preview::is_f_string_formatting_enabled;
use crate::string::{Quoting, StringNormalizer, StringQuotes};
use super::f_string_element::FormatFStringElement; use super::f_string_element::FormatFStringElement;
@ -29,8 +31,17 @@ impl Format<PyFormatContext<'_>> for FormatFString<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
let locator = f.context().locator(); 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()) let normalizer = StringNormalizer::from_context(f.context())
.with_quoting(self.quoting) .with_quoting(quoting)
.with_preferred_quote_style(f.options().quote_style()); .with_preferred_quote_style(f.options().quote_style());
// If f-string formatting is disabled (not in preview), then we will // If f-string formatting is disabled (not in preview), then we will
@ -140,3 +151,20 @@ impl FStringLayout {
matches!(self, FStringLayout::Multiline) 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
}
}

View file

@ -1,7 +1,7 @@
use ruff_python_ast::FStringPart; use ruff_python_ast::FStringPart;
use crate::other::f_string::FormatFString; use crate::other::f_string::FormatFString;
use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind}; use crate::other::string_literal::StringLiteralKind;
use crate::prelude::*; use crate::prelude::*;
use crate::string::Quoting; use crate::string::Quoting;
@ -25,13 +25,12 @@ impl<'a> FormatFStringPart<'a> {
impl Format<PyFormatContext<'_>> for FormatFStringPart<'_> { impl Format<PyFormatContext<'_>> for FormatFStringPart<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
match self.part { match self.part {
FStringPart::Literal(string_literal) => FormatStringLiteral::new( #[allow(deprecated)]
string_literal, FStringPart::Literal(string_literal) => string_literal
// If an f-string part is a string literal, the f-string is always .format()
// implicitly concatenated e.g., `"foo" f"bar {x}"`. A standalone .with_options(StringLiteralKind::InImplicitlyConcatenatedFString(
// string literal would be a string expression, not an f-string. self.quoting,
StringLiteralKind::InImplicitlyConcatenatedFString(self.quoting), ))
)
.fmt(f), .fmt(f),
FStringPart::FString(f_string) => FormatFString::new(f_string, self.quoting).fmt(f), FStringPart::FString(f_string) => FormatFString::new(f_string, self.quoting).fmt(f),
} }

View file

@ -1,23 +1,28 @@
use ruff_formatter::FormatRuleWithOptions;
use ruff_python_ast::StringLiteral; use ruff_python_ast::StringLiteral;
use crate::prelude::*; use crate::prelude::*;
use crate::preview::is_f_string_implicit_concatenated_string_literal_quotes_enabled;
use crate::string::{docstring, Quoting, StringNormalizer}; use crate::string::{docstring, Quoting, StringNormalizer};
use crate::QuoteStyle; use crate::QuoteStyle;
pub(crate) struct FormatStringLiteral<'a> { #[derive(Default)]
value: &'a StringLiteral, pub struct FormatStringLiteral {
layout: StringLiteralKind, layout: StringLiteralKind,
} }
impl<'a> FormatStringLiteral<'a> { impl FormatRuleWithOptions<StringLiteral, PyFormatContext<'_>> for FormatStringLiteral {
pub(crate) fn new(value: &'a StringLiteral, layout: StringLiteralKind) -> Self { type Options = StringLiteralKind;
Self { value, layout }
fn with_options(mut self, layout: StringLiteralKind) -> Self {
self.layout = layout;
self
} }
} }
/// The kind of a string literal. /// The kind of a string literal.
#[derive(Copy, Clone, Debug, Default)] #[derive(Copy, Clone, Debug, Default)]
pub(crate) enum StringLiteralKind { pub enum StringLiteralKind {
/// A normal string literal e.g., `"foo"`. /// A normal string literal e.g., `"foo"`.
#[default] #[default]
String, String,
@ -26,6 +31,8 @@ pub(crate) enum StringLiteralKind {
/// A string literal that is implicitly concatenated with an f-string. This /// A string literal that is implicitly concatenated with an f-string. This
/// makes the overall expression an f-string whose quoting detection comes /// makes the overall expression an f-string whose quoting detection comes
/// from the parent node (f-string expression). /// from the parent node (f-string expression).
#[deprecated]
#[allow(private_interfaces)]
InImplicitlyConcatenatedFString(Quoting), InImplicitlyConcatenatedFString(Quoting),
} }
@ -36,16 +43,28 @@ impl StringLiteralKind {
} }
/// Returns the quoting to be used for this string literal. /// Returns the quoting to be used for this string literal.
fn quoting(self) -> Quoting { fn quoting(self, context: &PyFormatContext) -> Quoting {
match self { match self {
StringLiteralKind::String | StringLiteralKind::Docstring => Quoting::CanChange, 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<PyFormatContext<'_>> for FormatStringLiteral<'_> { impl FormatNodeRule<StringLiteral> for FormatStringLiteral {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { fn fmt_fields(&self, item: &StringLiteral, f: &mut PyFormatter) -> FormatResult<()> {
let quote_style = f.options().quote_style(); let quote_style = f.options().quote_style();
let quote_style = if self.layout.is_docstring() && !quote_style.is_preserve() { let quote_style = if self.layout.is_docstring() && !quote_style.is_preserve() {
// Per PEP 8 and PEP 257, always prefer double quotes for docstrings, // Per PEP 8 and PEP 257, always prefer double quotes for docstrings,
@ -56,9 +75,9 @@ impl Format<PyFormatContext<'_>> for FormatStringLiteral<'_> {
}; };
let normalized = StringNormalizer::from_context(f.context()) 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) .with_preferred_quote_style(quote_style)
.normalize(self.value.into()); .normalize(item.into());
if self.layout.is_docstring() { if self.layout.is_docstring() {
docstring::format(&normalized, f) docstring::format(&normalized, f)

View file

@ -19,6 +19,13 @@ pub(crate) fn is_f_string_formatting_enabled(context: &PyFormatContext) -> bool
context.is_preview() 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 { pub(crate) fn is_with_single_item_pre_39_enabled(context: &PyFormatContext) -> bool {
context.is_preview() context.is_preview()
} }

View file

@ -11,7 +11,7 @@ use crate::comments::{
leading_comments, trailing_comments, Comments, LeadingDanglingTrailingComments, leading_comments, trailing_comments, Comments, LeadingDanglingTrailingComments,
}; };
use crate::context::{NodeLevel, TopLevelStatementPosition, WithIndentLevel, WithNodeLevel}; 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::prelude::*;
use crate::statement::stmt_expr::FormatStmtExpr; use crate::statement::stmt_expr::FormatStmtExpr;
use crate::verbatim::{ use crate::verbatim::{
@ -850,7 +850,7 @@ impl Format<PyFormatContext<'_>> for DocstringStmt<'_> {
.then_some(source_position(self.docstring.start())), .then_some(source_position(self.docstring.start())),
string_literal string_literal
.format() .format()
.with_options(ExprStringLiteralKind::Docstring), .with_options(StringLiteralKind::Docstring),
f.options() f.options()
.source_map_generation() .source_map_generation()
.is_enabled() .is_enabled()

View file

@ -11,7 +11,7 @@ use ruff_text_size::{Ranged, TextRange};
use crate::expression::expr_f_string::f_string_quoting; use crate::expression::expr_f_string::f_string_quoting;
use crate::other::f_string::FormatFString; use crate::other::f_string::FormatFString;
use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind}; use crate::other::string_literal::StringLiteralKind;
use crate::prelude::*; use crate::prelude::*;
use crate::string::Quoting; use crate::string::Quoting;
@ -160,6 +160,7 @@ impl<'a> Iterator for AnyStringPartsIter<'a> {
match part { match part {
ast::FStringPart::Literal(string_literal) => AnyStringPart::String { ast::FStringPart::Literal(string_literal) => AnyStringPart::String {
part: string_literal, part: string_literal,
#[allow(deprecated)]
layout: StringLiteralKind::InImplicitlyConcatenatedFString(*quoting), layout: StringLiteralKind::InImplicitlyConcatenatedFString(*quoting),
}, },
ast::FStringPart::FString(f_string) => AnyStringPart::FString { ast::FStringPart::FString(f_string) => AnyStringPart::FString {
@ -226,9 +227,7 @@ impl Ranged for AnyStringPart<'_> {
impl Format<PyFormatContext<'_>> for AnyStringPart<'_> { impl Format<PyFormatContext<'_>> for AnyStringPart<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
match self { match self {
AnyStringPart::String { part, layout } => { AnyStringPart::String { part, layout } => part.format().with_options(*layout).fmt(f),
FormatStringLiteral::new(part, *layout).fmt(f)
}
AnyStringPart::Bytes(bytes_literal) => bytes_literal.format().fmt(f), AnyStringPart::Bytes(bytes_literal) => bytes_literal.format().fmt(f),
AnyStringPart::FString { part, quoting } => FormatFString::new(part, *quoting).fmt(f), AnyStringPart::FString { part, quoting } => FormatFString::new(part, *quoting).fmt(f),
} }

View file

@ -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 ## 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 27
# comment 28 # comment 28
} woah {x}" } woah {x}"
@@ -287,19 +299,19 @@ @@ -287,27 +299,27 @@
if indent2: if indent2:
foo = f"""hello world foo = f"""hello world
hello { 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."
)
``` ```