Implement template strings (#17851)

This PR implements template strings (t-strings) in the parser and
formatter for Ruff.

Minimal changes necessary to compile were made in other parts of the code (e.g. ty, the linter, etc.). These will be covered properly in follow-up PRs.
This commit is contained in:
Dylan 2025-05-30 15:00:56 -05:00 committed by GitHub
parent ad024f9a09
commit 9bbf4987e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
261 changed files with 18023 additions and 1802 deletions

View file

@ -205,14 +205,14 @@ impl<'fmt, 'ast, 'buf> JoinCommaSeparatedBuilder<'fmt, 'ast, 'buf> {
pub(crate) fn finish(&mut self) -> FormatResult<()> {
self.result.and_then(|()| {
// Don't add a magic trailing comma when formatting an f-string expression
// Don't add a magic trailing comma when formatting an f-string or t-string expression
// that always must be flat because the `expand_parent` forces enclosing
// groups to expand, e.g. `print(f"{(a,)} ")` would format the f-string in
// flat mode but the `print` call gets expanded because of the `expand_parent`.
if self
.fmt
.context()
.f_string_state()
.interpolated_string_state()
.can_contain_line_breaks()
== Some(false)
{

View file

@ -314,15 +314,14 @@ fn handle_enclosed_comment<'a>(
AnyNodeRef::StmtImportFrom(import_from) => handle_import_from_comment(comment, import_from),
AnyNodeRef::StmtWith(with_) => handle_with_comment(comment, with_),
AnyNodeRef::ExprCall(_) => handle_call_comment(comment),
AnyNodeRef::ExprStringLiteral(_) => {
if let Some(AnyNodeRef::FString(fstring)) = comment.enclosing_parent() {
CommentPlacement::dangling(fstring, comment)
} else {
CommentPlacement::Default(comment)
}
}
AnyNodeRef::ExprStringLiteral(_) => match comment.enclosing_parent() {
Some(AnyNodeRef::FString(fstring)) => CommentPlacement::dangling(fstring, comment),
Some(AnyNodeRef::TString(tstring)) => CommentPlacement::dangling(tstring, comment),
_ => CommentPlacement::Default(comment),
},
AnyNodeRef::FString(fstring) => CommentPlacement::dangling(fstring, comment),
AnyNodeRef::FStringExpressionElement(_) => {
AnyNodeRef::TString(tstring) => CommentPlacement::dangling(tstring, comment),
AnyNodeRef::InterpolatedElement(_) => {
// Handle comments after the format specifier (should be rare):
//
// ```python
@ -336,7 +335,8 @@ fn handle_enclosed_comment<'a>(
if matches!(
comment.preceding_node(),
Some(
AnyNodeRef::FStringExpressionElement(_) | AnyNodeRef::FStringLiteralElement(_)
AnyNodeRef::InterpolatedElement(_)
| AnyNodeRef::InterpolatedStringLiteralElement(_)
)
) {
CommentPlacement::trailing(comment.enclosing_node(), comment)
@ -344,6 +344,7 @@ fn handle_enclosed_comment<'a>(
handle_bracketed_end_of_line_comment(comment, source)
}
}
AnyNodeRef::ExprList(_)
| AnyNodeRef::ExprSet(_)
| AnyNodeRef::ExprListComp(_)

View file

@ -7,7 +7,7 @@ use ruff_python_parser::Tokens;
use crate::PyFormatOptions;
use crate::comments::Comments;
use crate::other::f_string_element::FStringExpressionElementContext;
use crate::other::interpolated_string_element::InterpolatedElementContext;
pub struct PyFormatContext<'a> {
options: PyFormatOptions,
@ -25,8 +25,8 @@ pub struct PyFormatContext<'a> {
/// quote style that is inverted from the one here in order to ensure that
/// the formatted Python code will be valid.
docstring: Option<Quote>,
/// The state of the formatter with respect to f-strings.
f_string_state: FStringState,
/// The state of the formatter with respect to f-strings and t-strings.
interpolated_string_state: InterpolatedStringState,
}
impl<'a> PyFormatContext<'a> {
@ -44,7 +44,7 @@ impl<'a> PyFormatContext<'a> {
node_level: NodeLevel::TopLevel(TopLevelStatementPosition::Other),
indent_level: IndentLevel::new(0),
docstring: None,
f_string_state: FStringState::Outside,
interpolated_string_state: InterpolatedStringState::Outside,
}
}
@ -97,12 +97,15 @@ impl<'a> PyFormatContext<'a> {
}
}
pub(crate) fn f_string_state(&self) -> FStringState {
self.f_string_state
pub(crate) fn interpolated_string_state(&self) -> InterpolatedStringState {
self.interpolated_string_state
}
pub(crate) fn set_f_string_state(&mut self, f_string_state: FStringState) {
self.f_string_state = f_string_state;
pub(crate) fn set_interpolated_string_state(
&mut self,
interpolated_string_state: InterpolatedStringState,
) {
self.interpolated_string_state = interpolated_string_state;
}
/// Returns `true` if preview mode is enabled.
@ -135,24 +138,24 @@ impl Debug for PyFormatContext<'_> {
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) enum FStringState {
pub(crate) enum InterpolatedStringState {
/// The formatter is inside an f-string expression element i.e., between the
/// curly brace in `f"foo {x}"`.
///
/// The containing `FStringContext` is the surrounding f-string context.
InsideExpressionElement(FStringExpressionElementContext),
InsideInterpolatedElement(InterpolatedElementContext),
/// The formatter is outside an f-string.
#[default]
Outside,
}
impl FStringState {
impl InterpolatedStringState {
pub(crate) fn can_contain_line_breaks(self) -> Option<bool> {
match self {
FStringState::InsideExpressionElement(context) => {
InterpolatedStringState::InsideInterpolatedElement(context) => {
Some(context.can_contain_line_breaks())
}
FStringState::Outside => None,
InterpolatedStringState::Outside => None,
}
}
}
@ -375,25 +378,25 @@ where
}
}
pub(crate) struct WithFStringState<'a, B, D>
pub(crate) struct WithInterpolatedStringState<'a, B, D>
where
D: DerefMut<Target = B>,
B: Buffer<Context = PyFormatContext<'a>>,
{
buffer: D,
saved_location: FStringState,
saved_location: InterpolatedStringState,
}
impl<'a, B, D> WithFStringState<'a, B, D>
impl<'a, B, D> WithInterpolatedStringState<'a, B, D>
where
D: DerefMut<Target = B>,
B: Buffer<Context = PyFormatContext<'a>>,
{
pub(crate) fn new(expr_location: FStringState, mut buffer: D) -> Self {
pub(crate) fn new(expr_location: InterpolatedStringState, mut buffer: D) -> Self {
let context = buffer.state_mut().context_mut();
let saved_location = context.f_string_state();
let saved_location = context.interpolated_string_state();
context.set_f_string_state(expr_location);
context.set_interpolated_string_state(expr_location);
Self {
buffer,
@ -402,7 +405,7 @@ where
}
}
impl<'a, B, D> Deref for WithFStringState<'a, B, D>
impl<'a, B, D> Deref for WithInterpolatedStringState<'a, B, D>
where
D: DerefMut<Target = B>,
B: Buffer<Context = PyFormatContext<'a>>,
@ -414,7 +417,7 @@ where
}
}
impl<'a, B, D> DerefMut for WithFStringState<'a, B, D>
impl<'a, B, D> DerefMut for WithInterpolatedStringState<'a, B, D>
where
D: DerefMut<Target = B>,
B: Buffer<Context = PyFormatContext<'a>>,
@ -424,7 +427,7 @@ where
}
}
impl<'a, B, D> Drop for WithFStringState<'a, B, D>
impl<'a, B, D> Drop for WithInterpolatedStringState<'a, B, D>
where
D: DerefMut<Target = B>,
B: Buffer<Context = PyFormatContext<'a>>,
@ -433,6 +436,6 @@ where
self.buffer
.state_mut()
.context_mut()
.set_f_string_state(self.saved_location);
.set_interpolated_string_state(self.saved_location);
}
}

View file

@ -3,7 +3,7 @@ use ruff_python_ast::{AnyNodeRef, ExprFString, StringLike};
use crate::expression::parentheses::{
NeedsParentheses, OptionalParentheses, in_parentheses_only_group,
};
use crate::other::f_string::FStringLayout;
use crate::other::interpolated_string::InterpolatedStringLayout;
use crate::prelude::*;
use crate::string::StringLikeExtensions;
use crate::string::implicit::{
@ -41,7 +41,11 @@ impl NeedsParentheses for ExprFString {
if let Some(fstring_part) = self.as_single_part_fstring() {
// The f-string is not implicitly concatenated
if StringLike::FString(self).is_multiline(context)
|| FStringLayout::from_f_string(fstring_part, context.source()).is_multiline()
|| InterpolatedStringLayout::from_interpolated_string_elements(
&fstring_part.elements,
context.source(),
)
.is_multiline()
{
OptionalParentheses::Never
} else {

View file

@ -0,0 +1,59 @@
use ruff_python_ast::{AnyNodeRef, ExprTString, StringLike};
use crate::expression::parentheses::{
NeedsParentheses, OptionalParentheses, in_parentheses_only_group,
};
use crate::other::interpolated_string::InterpolatedStringLayout;
use crate::prelude::*;
use crate::string::StringLikeExtensions;
use crate::string::implicit::{
FormatImplicitConcatenatedString, FormatImplicitConcatenatedStringFlat,
};
#[derive(Default)]
pub struct FormatExprTString;
impl FormatNodeRule<ExprTString> for FormatExprTString {
fn fmt_fields(&self, item: &ExprTString, f: &mut PyFormatter) -> FormatResult<()> {
if let Some(t_string) = item.as_single_part_tstring() {
t_string.format().fmt(f)
} else {
// Always join tstrings that aren't parenthesized and thus, are always on a single line.
if !f.context().node_level().is_parenthesized() {
if let Some(format_flat) =
FormatImplicitConcatenatedStringFlat::new(item.into(), f.context())
{
return format_flat.fmt(f);
}
}
in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f)
}
}
}
impl NeedsParentheses for ExprTString {
fn needs_parentheses(
&self,
_parent: AnyNodeRef,
context: &PyFormatContext,
) -> OptionalParentheses {
if let Some(tstring_part) = self.as_single_part_tstring() {
// The t-string is not implicitly concatenated
if StringLike::TString(self).is_multiline(context)
|| InterpolatedStringLayout::from_interpolated_string_elements(
&tstring_part.elements,
context.source(),
)
.is_multiline()
{
OptionalParentheses::Never
} else {
OptionalParentheses::BestFit
}
} else {
// The t-string is implicitly concatenated
OptionalParentheses::Multiline
}
}
}

View file

@ -50,6 +50,7 @@ pub(crate) mod expr_slice;
pub(crate) mod expr_starred;
pub(crate) mod expr_string_literal;
pub(crate) mod expr_subscript;
pub(crate) mod expr_t_string;
pub(crate) mod expr_tuple;
pub(crate) mod expr_unary_op;
pub(crate) mod expr_yield;
@ -94,6 +95,7 @@ impl FormatRule<Expr, PyFormatContext<'_>> for FormatExpr {
Expr::Compare(expr) => expr.format().fmt(f),
Expr::Call(expr) => expr.format().fmt(f),
Expr::FString(expr) => expr.format().fmt(f),
Expr::TString(expr) => expr.format().fmt(f),
Expr::StringLiteral(expr) => expr.format().fmt(f),
Expr::BytesLiteral(expr) => expr.format().fmt(f),
Expr::NumberLiteral(expr) => expr.format().fmt(f),
@ -282,6 +284,7 @@ fn format_with_parentheses_comments(
Expr::Compare(expr) => FormatNodeRule::fmt_fields(expr.format().rule(), expr, f),
Expr::Call(expr) => FormatNodeRule::fmt_fields(expr.format().rule(), expr, f),
Expr::FString(expr) => FormatNodeRule::fmt_fields(expr.format().rule(), expr, f),
Expr::TString(expr) => FormatNodeRule::fmt_fields(expr.format().rule(), expr, f),
Expr::StringLiteral(expr) => FormatNodeRule::fmt_fields(expr.format().rule(), expr, f),
Expr::BytesLiteral(expr) => FormatNodeRule::fmt_fields(expr.format().rule(), expr, f),
Expr::NumberLiteral(expr) => FormatNodeRule::fmt_fields(expr.format().rule(), expr, f),
@ -480,6 +483,7 @@ impl NeedsParentheses for Expr {
Expr::Compare(expr) => expr.needs_parentheses(parent, context),
Expr::Call(expr) => expr.needs_parentheses(parent, context),
Expr::FString(expr) => expr.needs_parentheses(parent, context),
Expr::TString(expr) => expr.needs_parentheses(parent, context),
Expr::StringLiteral(expr) => expr.needs_parentheses(parent, context),
Expr::BytesLiteral(expr) => expr.needs_parentheses(parent, context),
Expr::NumberLiteral(expr) => expr.needs_parentheses(parent, context),
@ -775,6 +779,7 @@ impl<'input> CanOmitOptionalParenthesesVisitor<'input> {
// Terminal nodes or nodes that wrap a sub-expression (where the sub expression can never be at the end).
Expr::FString(_)
| Expr::TString(_)
| Expr::StringLiteral(_)
| Expr::BytesLiteral(_)
| Expr::NumberLiteral(_)
@ -1126,6 +1131,7 @@ pub(crate) fn is_expression_huggable(expr: &Expr, context: &PyFormatContext) ->
| Expr::StringLiteral(_)
| Expr::BytesLiteral(_)
| Expr::FString(_)
| Expr::TString(_)
| Expr::EllipsisLiteral(_) => false,
}
}
@ -1221,6 +1227,7 @@ pub(crate) fn is_splittable_expression(expr: &Expr, context: &PyFormatContext) -
// String like literals can expand if they are implicit concatenated.
Expr::FString(fstring) => fstring.value.is_implicit_concatenated(),
Expr::TString(tstring) => tstring.value.is_implicit_concatenated(),
Expr::StringLiteral(string) => string.value.is_implicit_concatenated(),
Expr::BytesLiteral(bytes) => bytes.value.is_implicit_concatenated(),
@ -1278,6 +1285,7 @@ pub(crate) fn left_most<'expr>(
| Expr::Name(_)
| Expr::Starred(_)
| Expr::FString(_)
| Expr::TString(_)
| Expr::StringLiteral(_)
| Expr::BytesLiteral(_)
| Expr::NumberLiteral(_)

View file

@ -1562,6 +1562,42 @@ impl<'ast> IntoFormat<PyFormatContext<'ast>> for ast::ExprFString {
}
}
impl FormatRule<ast::ExprTString, PyFormatContext<'_>>
for crate::expression::expr_t_string::FormatExprTString
{
#[inline]
fn fmt(&self, node: &ast::ExprTString, f: &mut PyFormatter) -> FormatResult<()> {
FormatNodeRule::<ast::ExprTString>::fmt(self, node, f)
}
}
impl<'ast> AsFormat<PyFormatContext<'ast>> for ast::ExprTString {
type Format<'a> = FormatRefWithRule<
'a,
ast::ExprTString,
crate::expression::expr_t_string::FormatExprTString,
PyFormatContext<'ast>,
>;
fn format(&self) -> Self::Format<'_> {
FormatRefWithRule::new(
self,
crate::expression::expr_t_string::FormatExprTString::default(),
)
}
}
impl<'ast> IntoFormat<PyFormatContext<'ast>> for ast::ExprTString {
type Format = FormatOwnedWithRule<
ast::ExprTString,
crate::expression::expr_t_string::FormatExprTString,
PyFormatContext<'ast>,
>;
fn into_format(self) -> Self::Format {
FormatOwnedWithRule::new(
self,
crate::expression::expr_t_string::FormatExprTString::default(),
)
}
}
impl FormatRule<ast::ExprStringLiteral, PyFormatContext<'_>>
for crate::expression::expr_string_literal::FormatExprStringLiteral
{
@ -2963,6 +2999,34 @@ impl<'ast> IntoFormat<PyFormatContext<'ast>> for ast::FString {
}
}
impl FormatRule<ast::TString, PyFormatContext<'_>> for crate::other::t_string::FormatTString {
#[inline]
fn fmt(&self, node: &ast::TString, f: &mut PyFormatter) -> FormatResult<()> {
FormatNodeRule::<ast::TString>::fmt(self, node, f)
}
}
impl<'ast> AsFormat<PyFormatContext<'ast>> for ast::TString {
type Format<'a> = FormatRefWithRule<
'a,
ast::TString,
crate::other::t_string::FormatTString,
PyFormatContext<'ast>,
>;
fn format(&self) -> Self::Format<'_> {
FormatRefWithRule::new(self, crate::other::t_string::FormatTString::default())
}
}
impl<'ast> IntoFormat<PyFormatContext<'ast>> for ast::TString {
type Format = FormatOwnedWithRule<
ast::TString,
crate::other::t_string::FormatTString,
PyFormatContext<'ast>,
>;
fn into_format(self) -> Self::Format {
FormatOwnedWithRule::new(self, crate::other::t_string::FormatTString::default())
}
}
impl FormatRule<ast::StringLiteral, PyFormatContext<'_>>
for crate::other::string_literal::FormatStringLiteral
{

View file

@ -1,12 +1,9 @@
use ruff_formatter::write;
use ruff_python_ast::{AnyStringFlags, FString, StringFlags};
use ruff_source_file::LineRanges;
use ruff_text_size::Ranged;
use super::interpolated_string_element::FormatInterpolatedStringElement;
use crate::other::interpolated_string::{InterpolatedStringContext, InterpolatedStringLayout};
use crate::prelude::*;
use crate::string::{StringNormalizer, StringQuotes};
use super::f_string_element::FormatFStringElement;
use ruff_formatter::write;
use ruff_python_ast::{FString, StringFlags};
/// Formats an f-string which is part of a larger f-string expression.
///
@ -21,9 +18,12 @@ impl FormatNodeRule<FString> for FormatFString {
let string_kind = normalizer.choose_quotes(item.into()).flags();
let context = FStringContext::new(
let context = InterpolatedStringContext::new(
string_kind,
FStringLayout::from_f_string(item, f.context().source()),
InterpolatedStringLayout::from_interpolated_string_elements(
&item.elements,
f.context().source(),
),
);
// Starting prefix and quote
@ -31,78 +31,10 @@ impl FormatNodeRule<FString> for FormatFString {
write!(f, [string_kind.prefix(), quotes])?;
for element in &item.elements {
FormatFStringElement::new(element, context).fmt(f)?;
FormatInterpolatedStringElement::new(element, context).fmt(f)?;
}
// Ending quote
quotes.fmt(f)
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct FStringContext {
/// The string flags of the enclosing f-string part.
enclosing_flags: AnyStringFlags,
layout: FStringLayout,
}
impl FStringContext {
pub(crate) const fn new(flags: AnyStringFlags, layout: FStringLayout) -> Self {
Self {
enclosing_flags: flags,
layout,
}
}
pub(crate) fn flags(self) -> AnyStringFlags {
self.enclosing_flags
}
pub(crate) const fn layout(self) -> FStringLayout {
self.layout
}
}
#[derive(Copy, Clone, Debug)]
pub(crate) enum FStringLayout {
/// Original f-string is flat.
/// Don't break expressions to keep the string flat.
Flat,
/// Original f-string has multiline expressions in the replacement fields.
/// Allow breaking expressions across multiple lines.
Multiline,
}
impl FStringLayout {
pub(crate) fn from_f_string(f_string: &FString, source: &str) -> Self {
// Heuristic: Allow breaking the f-string expressions across multiple lines
// only if there already is at least one multiline expression. This puts the
// control in the hands of the user to decide if they want to break the
// f-string expressions across multiple lines or not. This is similar to
// how Prettier does it for template literals in JavaScript.
//
// If it's single quoted f-string and it contains a multiline expression, then we
// assume that the target version of Python supports it (3.12+). If there are comments
// used in any of the expression of the f-string, then it's always going to be multiline
// and we assume that the target version of Python supports it (3.12+).
//
// Reference: https://prettier.io/docs/en/next/rationale.html#template-literals
if f_string
.elements
.expressions()
.any(|expr| source.contains_line_break(expr.range()))
{
Self::Multiline
} else {
Self::Flat
}
}
pub(crate) const fn is_flat(self) -> bool {
matches!(self, FStringLayout::Flat)
}
pub(crate) const fn is_multiline(self) -> bool {
matches!(self, FStringLayout::Multiline)
}
}

View file

@ -0,0 +1,73 @@
use ruff_python_ast::{AnyStringFlags, InterpolatedStringElements};
use ruff_source_file::LineRanges;
use ruff_text_size::Ranged;
#[derive(Clone, Copy, Debug)]
pub(crate) struct InterpolatedStringContext {
/// The string flags of the enclosing f/t-string part.
enclosing_flags: AnyStringFlags,
layout: InterpolatedStringLayout,
}
impl InterpolatedStringContext {
pub(crate) const fn new(flags: AnyStringFlags, layout: InterpolatedStringLayout) -> Self {
Self {
enclosing_flags: flags,
layout,
}
}
pub(crate) fn flags(self) -> AnyStringFlags {
self.enclosing_flags
}
pub(crate) const fn layout(self) -> InterpolatedStringLayout {
self.layout
}
}
#[derive(Copy, Clone, Debug)]
pub(crate) enum InterpolatedStringLayout {
/// Original f/t-string is flat.
/// Don't break expressions to keep the string flat.
Flat,
/// Original f/t-string has multiline expressions in the replacement fields.
/// Allow breaking expressions across multiple lines.
Multiline,
}
impl InterpolatedStringLayout {
// Heuristic: Allow breaking the f/t-string expressions across multiple lines
// only if there already is at least one multiline expression. This puts the
// control in the hands of the user to decide if they want to break the
// f/t-string expressions across multiple lines or not. This is similar to
// how Prettier does it for template literals in JavaScript.
//
// If it's single quoted f-string and it contains a multiline expression, then we
// assume that the target version of Python supports it (3.12+). If there are comments
// used in any of the expression of the f-string, then it's always going to be multiline
// and we assume that the target version of Python supports it (3.12+).
//
// Reference: https://prettier.io/docs/en/next/rationale.html#template-literals
pub(crate) fn from_interpolated_string_elements(
elements: &InterpolatedStringElements,
source: &str,
) -> Self {
if elements
.interpolations()
.any(|expr| source.contains_line_break(expr.range()))
{
Self::Multiline
} else {
Self::Flat
}
}
pub(crate) const fn is_flat(self) -> bool {
matches!(self, InterpolatedStringLayout::Flat)
}
pub(crate) const fn is_multiline(self) -> bool {
matches!(self, InterpolatedStringLayout::Multiline)
}
}

View file

@ -2,42 +2,47 @@ use std::borrow::Cow;
use ruff_formatter::{Buffer, RemoveSoftLinesBuffer, format_args, write};
use ruff_python_ast::{
AnyStringFlags, ConversionFlag, Expr, FStringElement, FStringExpressionElement,
FStringLiteralElement, StringFlags,
AnyStringFlags, ConversionFlag, Expr, InterpolatedElement, InterpolatedStringElement,
InterpolatedStringLiteralElement, StringFlags,
};
use ruff_text_size::{Ranged, TextSlice};
use crate::comments::{dangling_open_parenthesis_comments, trailing_comments};
use crate::context::{FStringState, NodeLevel, WithFStringState, WithNodeLevel};
use crate::context::{
InterpolatedStringState, NodeLevel, WithInterpolatedStringState, WithNodeLevel,
};
use crate::expression::left_most;
use crate::prelude::*;
use crate::string::normalize_string;
use crate::verbatim::verbatim_text;
use super::f_string::FStringContext;
use super::interpolated_string::InterpolatedStringContext;
/// Formats an f-string element which is either a literal or a formatted expression.
///
/// This delegates the actual formatting to the appropriate formatter.
pub(crate) struct FormatFStringElement<'a> {
element: &'a FStringElement,
context: FStringContext,
pub(crate) struct FormatInterpolatedStringElement<'a> {
element: &'a InterpolatedStringElement,
context: InterpolatedStringContext,
}
impl<'a> FormatFStringElement<'a> {
pub(crate) fn new(element: &'a FStringElement, context: FStringContext) -> Self {
impl<'a> FormatInterpolatedStringElement<'a> {
pub(crate) fn new(
element: &'a InterpolatedStringElement,
context: InterpolatedStringContext,
) -> Self {
Self { element, context }
}
}
impl Format<PyFormatContext<'_>> for FormatFStringElement<'_> {
impl Format<PyFormatContext<'_>> for FormatInterpolatedStringElement<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
match self.element {
FStringElement::Literal(string_literal) => {
InterpolatedStringElement::Literal(string_literal) => {
FormatFStringLiteralElement::new(string_literal, self.context.flags()).fmt(f)
}
FStringElement::Expression(expression) => {
FormatFStringExpressionElement::new(expression, self.context).fmt(f)
InterpolatedStringElement::Interpolation(expression) => {
FormatInterpolatedElement::new(expression, self.context).fmt(f)
}
}
}
@ -45,13 +50,16 @@ impl Format<PyFormatContext<'_>> for FormatFStringElement<'_> {
/// Formats an f-string literal element.
pub(crate) struct FormatFStringLiteralElement<'a> {
element: &'a FStringLiteralElement,
element: &'a InterpolatedStringLiteralElement,
/// Flags of the enclosing F-string part
fstring_flags: AnyStringFlags,
}
impl<'a> FormatFStringLiteralElement<'a> {
pub(crate) fn new(element: &'a FStringLiteralElement, fstring_flags: AnyStringFlags) -> Self {
pub(crate) fn new(
element: &'a InterpolatedStringLiteralElement,
fstring_flags: AnyStringFlags,
) -> Self {
Self {
element,
fstring_flags,
@ -72,16 +80,16 @@ impl Format<PyFormatContext<'_>> for FormatFStringLiteralElement<'_> {
/// Context representing an f-string expression element.
#[derive(Clone, Copy, Debug)]
pub(crate) struct FStringExpressionElementContext {
pub(crate) struct InterpolatedElementContext {
/// The context of the parent f-string containing this expression element.
parent_context: FStringContext,
parent_context: InterpolatedStringContext,
/// Indicates whether this expression element has format specifier or not.
has_format_spec: bool,
}
impl FStringExpressionElementContext {
/// Returns the [`FStringContext`] containing this expression element.
pub(crate) fn f_string(self) -> FStringContext {
impl InterpolatedElementContext {
/// Returns the [`InterpolatedStringContext`] containing this expression element.
pub(crate) fn interpolated_string(self) -> InterpolatedStringContext {
self.parent_context
}
@ -113,16 +121,19 @@ impl FStringExpressionElementContext {
}
/// Formats an f-string expression element.
pub(crate) struct FormatFStringExpressionElement<'a> {
element: &'a FStringExpressionElement,
context: FStringExpressionElementContext,
pub(crate) struct FormatInterpolatedElement<'a> {
element: &'a InterpolatedElement,
context: InterpolatedElementContext,
}
impl<'a> FormatFStringExpressionElement<'a> {
pub(crate) fn new(element: &'a FStringExpressionElement, context: FStringContext) -> Self {
impl<'a> FormatInterpolatedElement<'a> {
pub(crate) fn new(
element: &'a InterpolatedElement,
context: InterpolatedStringContext,
) -> Self {
Self {
element,
context: FStringExpressionElementContext {
context: InterpolatedElementContext {
parent_context: context,
has_format_spec: element.format_spec.is_some(),
},
@ -130,9 +141,9 @@ impl<'a> FormatFStringExpressionElement<'a> {
}
}
impl Format<PyFormatContext<'_>> for FormatFStringExpressionElement<'_> {
impl Format<PyFormatContext<'_>> for FormatInterpolatedElement<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
let FStringExpressionElement {
let InterpolatedElement {
expression,
debug_text,
conversion,
@ -214,8 +225,8 @@ impl Format<PyFormatContext<'_>> for FormatFStringExpressionElement<'_> {
let item = format_with(|f: &mut PyFormatter| {
// Update the context to be inside the f-string expression element.
let f = &mut WithFStringState::new(
FStringState::InsideExpressionElement(self.context),
let f = &mut WithInterpolatedStringState::new(
InterpolatedStringState::InsideInterpolatedElement(self.context),
f,
);
@ -233,7 +244,11 @@ impl Format<PyFormatContext<'_>> for FormatFStringExpressionElement<'_> {
token(":").fmt(f)?;
for element in &format_spec.elements {
FormatFStringElement::new(element, self.context.f_string()).fmt(f)?;
FormatInterpolatedStringElement::new(
element,
self.context.interpolated_string(),
)
.fmt(f)?;
}
// These trailing comments can only occur if the format specifier is

View file

@ -7,12 +7,14 @@ pub(crate) mod decorator;
pub(crate) mod elif_else_clause;
pub(crate) mod except_handler_except_handler;
pub(crate) mod f_string;
pub(crate) mod f_string_element;
pub(crate) mod identifier;
pub(crate) mod interpolated_string;
pub(crate) mod interpolated_string_element;
pub(crate) mod keyword;
pub(crate) mod match_case;
pub(crate) mod parameter;
pub(crate) mod parameter_with_default;
pub(crate) mod parameters;
pub(crate) mod string_literal;
pub(crate) mod t_string;
pub(crate) mod with_item;

View file

@ -0,0 +1,40 @@
use super::interpolated_string_element::FormatInterpolatedStringElement;
use crate::other::interpolated_string::{InterpolatedStringContext, InterpolatedStringLayout};
use crate::prelude::*;
use crate::string::{StringNormalizer, StringQuotes};
use ruff_formatter::write;
use ruff_python_ast::{StringFlags, TString};
/// Formats a t-string which is part of a larger t-string expression.
///
/// For example, this would be used to format the t-string part in `"foo" t"bar {x}"`
/// or the standalone t-string in `t"foo {x} bar"`.
#[derive(Default)]
pub struct FormatTString;
impl FormatNodeRule<TString> for FormatTString {
fn fmt_fields(&self, item: &TString, f: &mut PyFormatter) -> FormatResult<()> {
let normalizer = StringNormalizer::from_context(f.context());
let string_kind = normalizer.choose_quotes(item.into()).flags();
let context = InterpolatedStringContext::new(
string_kind,
InterpolatedStringLayout::from_interpolated_string_elements(
&item.elements,
f.context().source(),
),
);
// Starting prefix and quote
let quotes = StringQuotes::from(string_kind);
write!(f, [string_kind.prefix(), quotes])?;
for element in &item.elements {
FormatInterpolatedStringElement::new(element, context).fmt(f)?;
}
// Ending quote
quotes.fmt(f)
}
}

View file

@ -293,6 +293,7 @@ impl<'a> CanOmitOptionalParenthesesVisitor<'a> {
// 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(_) |
Expr::TString(_)|
Expr::NumberLiteral(_) | Expr::Attribute(_) | Expr::UnaryOp(_) => {
// require no state update other than visit_pattern does.
}
@ -306,7 +307,7 @@ impl<'a> CanOmitOptionalParenthesesVisitor<'a> {
_ => {
debug_assert!(
false,
"Unsupported expression in pattern mach value: {:?}",
"Unsupported expression in pattern match value: {:?}",
value.value
);
}

View file

@ -659,10 +659,11 @@ impl Format<PyFormatContext<'_>> for FormatEnclosingNode<'_> {
| AnyNodeRef::ExprYieldFrom(_)
| AnyNodeRef::ExprCompare(_)
| AnyNodeRef::ExprCall(_)
| AnyNodeRef::FStringExpressionElement(_)
| AnyNodeRef::FStringLiteralElement(_)
| AnyNodeRef::FStringFormatSpec(_)
| AnyNodeRef::InterpolatedElement(_)
| AnyNodeRef::InterpolatedStringLiteralElement(_)
| AnyNodeRef::InterpolatedStringFormatSpec(_)
| AnyNodeRef::ExprFString(_)
| AnyNodeRef::ExprTString(_)
| AnyNodeRef::ExprStringLiteral(_)
| AnyNodeRef::ExprBytesLiteral(_)
| AnyNodeRef::ExprNumberLiteral(_)
@ -679,6 +680,7 @@ impl Format<PyFormatContext<'_>> for FormatEnclosingNode<'_> {
| AnyNodeRef::ExprIpyEscapeCommand(_)
| AnyNodeRef::FString(_)
| AnyNodeRef::StringLiteral(_)
| AnyNodeRef::TString(_)
| AnyNodeRef::PatternMatchValue(_)
| AnyNodeRef::PatternMatchSingleton(_)
| AnyNodeRef::PatternMatchSequence(_)

View file

@ -1,6 +1,6 @@
use ruff_formatter::{FormatError, RemoveSoftLinesBuffer, format_args, write};
use ruff_python_ast::{
AnyNodeRef, Expr, ExprAttribute, ExprCall, FString, Operator, StmtAssign, StringLike,
AnyNodeRef, Expr, ExprAttribute, ExprCall, FString, Operator, StmtAssign, StringLike, TString,
TypeParams,
};
@ -17,7 +17,7 @@ use crate::expression::{
can_omit_optional_parentheses, has_own_parentheses, has_parentheses,
maybe_parenthesize_expression,
};
use crate::other::f_string::FStringLayout;
use crate::other::interpolated_string::InterpolatedStringLayout;
use crate::statement::trailing_semicolon;
use crate::string::StringLikeExtensions;
use crate::string::implicit::{
@ -291,15 +291,16 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
let can_inline_comment = should_inline_comments(value, *statement, f.context());
let string_like = StringLike::try_from(*value).ok();
let format_f_string =
string_like.and_then(|string| format_f_string_assignment(string, f.context()));
let format_interpolated_string = string_like
.and_then(|string| format_interpolated_string_assignment(string, f.context()));
let format_implicit_flat = string_like.and_then(|string| {
FormatImplicitConcatenatedStringFlat::new(string, f.context())
});
if !can_inline_comment
&& format_implicit_flat.is_none()
&& format_f_string.is_none()
&& format_interpolated_string.is_none()
{
return maybe_parenthesize_expression(
value,
@ -351,7 +352,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
let string = flat.string();
let flat = format_with(|f| {
if string.is_fstring() {
if string.is_interpolated_string() {
let mut buffer = RemoveSoftLinesBuffer::new(&mut *f);
write!(buffer, [flat])
@ -361,7 +362,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
})
.memoized();
// F-String containing an expression with a magic trailing comma, a comment, or a
// F-string or T-string containing an expression with a magic trailing comma, a comment, or a
// multiline debug expression should never be joined. Use the default layout.
// ```python
// aaaa = f"abcd{[
@ -369,7 +370,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
// 2,
// ]}" "more"
// ```
if string.is_fstring() && flat.inspect(f)?.will_break() {
if string.is_interpolated_string() && flat.inspect(f)?.will_break() {
inline_comments.mark_unformatted();
return write!(
@ -446,24 +447,23 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
best_fitting![single_line, joined_parenthesized, implicit_expanded]
.with_mode(BestFittingMode::AllLines)
.fmt(f)?;
} else if let Some(format_f_string) = format_f_string {
} else if let Some(format_interpolated_string) = format_interpolated_string {
inline_comments.mark_formatted();
let f_string_flat = format_with(|f| {
let interpolated_string_flat = format_with(|f| {
let mut buffer = RemoveSoftLinesBuffer::new(&mut *f);
write!(buffer, [format_f_string.format()])
write!(buffer, [format_interpolated_string])
})
.memoized();
// F-String containing an expression with a magic trailing comma, a comment, or a
// multiline debug expression should never be joined. Use the default layout.
// F/T-String containing an interpolation with a magic trailing comma, a comment, or a
// multiline debug interpolation should never be joined. Use the default layout.
// ```python
// aaaa = f"aaaa {[
// 1, 2,
// ]} bbbb"
// ```
if f_string_flat.inspect(f)?.will_break() {
if interpolated_string_flat.inspect(f)?.will_break() {
inline_comments.mark_unformatted();
return write!(
@ -482,23 +482,26 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
// expression}moreeeeeeeeeeeeeeeee"
// ```
// Flatten the f-string.
// Flatten the f/t-string.
// ```python
// aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{expression}moreeeeeeeeeeeeeeeee"
// ```
let single_line =
format_with(|f| write!(f, [f_string_flat, inline_comments]));
format_with(|f| write!(f, [interpolated_string_flat, inline_comments]));
// Parenthesize the f-string and flatten the f-string.
// Parenthesize the t-string and flatten the t-string.
// ```python
// aaaaaaaaaaaaaaaaaa = (
// f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{expression}moreeeeeeeeeeeeeeeee"
// t"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{expression}moreeeeeeeeeeeeeeeee"
// )
// ```
let joined_parenthesized = format_with(|f| {
group(&format_args![
token("("),
soft_block_indent(&format_args![f_string_flat, inline_comments]),
soft_block_indent(&format_args![
interpolated_string_flat,
inline_comments
]),
token(")"),
])
.with_id(Some(group_id))
@ -506,19 +509,24 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
.fmt(f)
});
// Avoid flattening or parenthesizing the f-string, keep the original
// f-string formatting.
// Avoid flattening or parenthesizing the f/t-string, keep the original
// f/t-string formatting.
// ```python
// aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
// aaaaaaaaaaaaaaaaaa = t"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
// expression
// }moreeeeeeeeeeeeeeeee"
// ```
let format_f_string =
format_with(|f| write!(f, [format_f_string.format(), inline_comments]));
let format_interpolated_string = format_with(|f| {
write!(f, [format_interpolated_string, inline_comments])
});
best_fitting![single_line, joined_parenthesized, format_f_string]
.with_mode(BestFittingMode::AllLines)
.fmt(f)?;
best_fitting![
single_line,
joined_parenthesized,
format_interpolated_string
]
.with_mode(BestFittingMode::AllLines)
.fmt(f)?;
} else {
best_fit_parenthesize(&format_once(|f| {
inline_comments.mark_formatted();
@ -559,17 +567,16 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
let should_inline_comments = should_inline_comments(value, *statement, f.context());
let string_like = StringLike::try_from(*value).ok();
let format_f_string =
string_like.and_then(|string| format_f_string_assignment(string, f.context()));
let format_interpolated_string = string_like
.and_then(|string| format_interpolated_string_assignment(string, f.context()));
let format_implicit_flat = string_like.and_then(|string| {
FormatImplicitConcatenatedStringFlat::new(string, f.context())
});
// Use the normal `maybe_parenthesize_layout` for splittable `value`s.
if !should_inline_comments
&& !should_non_inlineable_use_best_fit(value, *statement, f.context())
&& format_implicit_flat.is_none()
&& format_f_string.is_none()
&& format_interpolated_string.is_none()
{
return write!(
f,
@ -593,7 +600,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
// Don't inline comments for attribute and call expressions for black compatibility
let inline_comments = if should_inline_comments
|| format_implicit_flat.is_some()
|| format_f_string.is_some()
|| format_interpolated_string.is_some()
{
OptionalParenthesesInlinedComments::new(
&expression_comments,
@ -633,7 +640,9 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
// This is mainly a performance optimisation that avoids unnecessary memoization
// and using the costly `BestFitting` layout if it is already known that only the last variant
// can ever fit because the left breaks.
if format_implicit_flat.is_none() && format_f_string.is_none() && last_target_breaks
if format_implicit_flat.is_none()
&& format_interpolated_string.is_none()
&& last_target_breaks
{
return write!(
f,
@ -650,7 +659,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
let format_value = format_with(|f| {
if let Some(format_implicit_flat) = format_implicit_flat.as_ref() {
if format_implicit_flat.string().is_fstring() {
if format_implicit_flat.string().is_interpolated_string() {
// Remove any soft line breaks emitted by the f-string formatting.
// This is important when formatting f-strings as part of an assignment right side
// because `best_fit_parenthesize` will otherwise still try to break inner
@ -660,11 +669,13 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
} else {
format_implicit_flat.fmt(f)
}
} else if let Some(format_f_string) = format_f_string.as_ref() {
} else if let Some(format_interpolated_string) =
format_interpolated_string.as_ref()
{
// Similar to above, remove any soft line breaks emitted by the f-string
// formatting.
let mut buffer = RemoveSoftLinesBuffer::new(&mut *f);
write!(buffer, [format_f_string.format()])
write!(buffer, [format_interpolated_string])
} else {
value.format().with_options(Parentheses::Never).fmt(f)
}
@ -766,7 +777,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
// 2,
// ]}" "more"
// ```
if format_implicit_flat.string().is_fstring()
if format_implicit_flat.string().is_interpolated_string()
&& format_value.inspect(f)?.will_break()
{
inline_comments.mark_unformatted();
@ -905,12 +916,12 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
.with_mode(BestFittingMode::AllLines)
.fmt(f)
}
} else if let Some(format_f_string) = &format_f_string {
// F-String containing an expression with a magic trailing comma, a comment, or a
} else if let Some(format_interpolated_string) = &format_interpolated_string {
// F/T-String containing an interpolation with a magic trailing comma, a comment, or a
// multiline debug expression should never be joined. Use the default layout.
//
// ```python
// aaaa, bbbb = f"aaaa {[
// aaaa, bbbb = t"aaaa {[
// 1, 2,
// ]} bbbb"
// ```
@ -933,40 +944,46 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
);
}
let format_f_string =
format_with(|f| write!(f, [format_f_string.format(), inline_comments]))
let format_interpolated_string =
format_with(|f| write!(f, [format_interpolated_string, inline_comments]))
.memoized();
// Considering the following initial source:
//
// ```python
// aaaaaaaaaaaa["bbbbbbbbbbbbbbbb"] = (
// f"aaaaaaaaaaaaaaaaaaa {
// t"aaaaaaaaaaaaaaaaaaa {
// aaaaaaaaa + bbbbbbbbbbb + cccccccccccccc} ddddddddddddddddddd"
// )
// ```
//
// Keep the target flat, and use the regular f-string formatting.
// Keep the target flat, and use the regular f/t-string formatting.
//
// ```python
// aaaaaaaaaaaa["bbbbbbbbbbbbbbbb"] = f"aaaaaaaaaaaaaaaaaaa {
// aaaaaaaaaaaa["bbbbbbbbbbbbbbbb"] = t"aaaaaaaaaaaaaaaaaaa {
// aaaaaaaaa + bbbbbbbbbbb + cccccccccccccc
// } ddddddddddddddddddd"
// ```
let flat_target_regular_f_string = format_with(|f| {
let flat_target_regular_interpolated_string = format_with(|f| {
write!(
f,
[last_target, space(), operator, space(), format_f_string]
[
last_target,
space(),
operator,
space(),
format_interpolated_string
]
)
});
// Expand the parent and parenthesize the flattened f-string.
// Expand the parent and parenthesize the flattened f/t-string.
//
// ```python
// aaaaaaaaaaaa[
// "bbbbbbbbbbbbbbbb"
// ] = (
// f"aaaaaaaaaaaaaaaaaaa {aaaaaaaaa + bbbbbbbbbbb + cccccccccccccc} ddddddddddddddddddd"
// t"aaaaaaaaaaaaaaaaaaa {aaaaaaaaa + bbbbbbbbbbb + cccccccccccccc} ddddddddddddddddddd"
// )
// ```
let split_target_value_parenthesized_flat = format_with(|f| {
@ -988,16 +1005,16 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
)
});
// Expand the parent, and use the regular f-string formatting.
// Expand the parent, and use the regular f/t-string formatting.
//
// ```python
// aaaaaaaaaaaa[
// "bbbbbbbbbbbbbbbb"
// ] = f"aaaaaaaaaaaaaaaaaaa {
// ] = t"aaaaaaaaaaaaaaaaaaa {
// aaaaaaaaa + bbbbbbbbbbb + cccccccccccccc
// } ddddddddddddddddddd"
// ```
let split_target_regular_f_string = format_with(|f| {
let split_target_regular_interpolated_string = format_with(|f| {
write!(
f,
[
@ -1005,7 +1022,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
space(),
operator,
space(),
format_f_string,
format_interpolated_string,
]
)
});
@ -1016,7 +1033,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
best_fitting![
split_target_flat_value,
split_target_value_parenthesized_flat,
split_target_regular_f_string,
split_target_regular_interpolated_string,
]
.with_mode(BestFittingMode::AllLines)
.fmt(f)
@ -1024,10 +1041,10 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
best_fitting![
single_line,
flat_target_parenthesize_value,
flat_target_regular_f_string,
flat_target_regular_interpolated_string,
split_target_flat_value,
split_target_value_parenthesized_flat,
split_target_regular_f_string,
split_target_regular_interpolated_string,
]
.with_mode(BestFittingMode::AllLines)
.fmt(f)
@ -1045,13 +1062,31 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
}
}
/// Formats an f-string that is at the value position of an assignment statement.
#[derive(Debug, Copy, Clone)]
enum InterpolatedString<'a> {
FString(&'a FString),
TString(&'a TString),
}
impl Format<PyFormatContext<'_>> for InterpolatedString<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
match self {
InterpolatedString::FString(string) => string.format().fmt(f),
InterpolatedString::TString(string) => string.format().fmt(f),
}
}
}
/// Formats an f/t-string that is at the value position of an assignment statement.
///
/// This is just a wrapper around [`FormatFString`] while considering a special case when the
/// f-string is at an assignment statement's value position.
/// For legibility, we discuss only the case of f-strings below, but the
/// same comments apply to t-strings.
///
/// This is necessary to prevent an instability where an f-string contains a multiline expression
/// and the f-string fits on the line, but only when it's surrounded by parentheses.
/// This is just a wrapper around [`FormatFString`] while considering a special
/// case when the f-string is at an assignment statement's value position.
/// This is necessary to prevent an instability where an f-string contains a
/// multiline expression and the f-string fits on the line, but only when it's
/// surrounded by parentheses.
///
/// ```python
/// aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{
@ -1099,30 +1134,40 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
/// The reason for this is because (a) f-string already has a multiline expression thus it tries to
/// break the expression and (b) the `BestFit` layout doesn't considers the layout where the
/// multiline f-string isn't surrounded by parentheses.
fn format_f_string_assignment<'a>(
fn format_interpolated_string_assignment<'a>(
string: StringLike<'a>,
context: &PyFormatContext,
) -> Option<&'a FString> {
let StringLike::FString(expr) = string else {
return None;
) -> Option<InterpolatedString<'a>> {
let (interpolated_string, elements) = match string {
StringLike::TString(expr) => {
let t_string = expr.as_single_part_tstring()?;
(InterpolatedString::TString(t_string), &t_string.elements)
}
StringLike::FString(expr) => {
let f_string = expr.as_single_part_fstring()?;
(InterpolatedString::FString(f_string), &f_string.elements)
}
_ => {
return None;
}
};
let f_string = expr.as_single_part_fstring()?;
// If the f-string is flat, there are no breakpoints from which it can be made multiline.
// This is the case when the f-string has no expressions or if it does then the expressions
// If the f/t-string is flat, there are no breakpoints from which it can be made multiline.
// This is the case when the f/t-string has no expressions or if it does then the expressions
// are flat (no newlines).
if FStringLayout::from_f_string(f_string, context.source()).is_flat() {
if InterpolatedStringLayout::from_interpolated_string_elements(elements, context.source())
.is_flat()
{
return None;
}
// This checks whether the f-string is multi-line and it can *never* be flattened. Thus,
// This checks whether the f/t-string is multi-line and it can *never* be flattened. Thus,
// it's useless to try the flattened layout.
if string.is_multiline(context) {
return None;
}
Some(f_string)
Some(interpolated_string)
}
#[derive(Debug, Default)]
@ -1277,6 +1322,9 @@ fn should_inline_comments(
Expr::FString(fstring) => {
fstring.needs_parentheses(parent, context) == OptionalParentheses::BestFit
}
Expr::TString(tstring) => {
tstring.needs_parentheses(parent, context) == OptionalParentheses::BestFit
}
_ => false,
}
}

View file

@ -2,28 +2,31 @@ use itertools::Itertools;
use ruff_formatter::{FormatContext, format_args, write};
use ruff_python_ast::str::{Quote, TripleQuotes};
use ruff_python_ast::str_prefix::{
AnyStringPrefix, ByteStringPrefix, FStringPrefix, StringLiteralPrefix,
AnyStringPrefix, ByteStringPrefix, FStringPrefix, StringLiteralPrefix, TStringPrefix,
};
use ruff_python_ast::{
AnyStringFlags, FString, InterpolatedStringElement, StringFlags, StringLike, StringLikePart,
TString,
};
use ruff_python_ast::{AnyStringFlags, FStringElement, StringFlags, StringLike, StringLikePart};
use ruff_source_file::LineRanges;
use ruff_text_size::{Ranged, TextRange};
use std::borrow::Cow;
use crate::comments::{leading_comments, trailing_comments};
use crate::expression::parentheses::in_parentheses_only_soft_line_break_or_space;
use crate::other::f_string::{FStringContext, FStringLayout};
use crate::other::f_string_element::FormatFStringExpressionElement;
use crate::other::interpolated_string::{InterpolatedStringContext, InterpolatedStringLayout};
use crate::other::interpolated_string_element::FormatInterpolatedElement;
use crate::prelude::*;
use crate::string::docstring::needs_chaperone_space;
use crate::string::normalize::{
QuoteMetadata, is_fstring_with_quoted_debug_expression,
is_fstring_with_quoted_format_spec_and_debug,
is_fstring_with_triple_quoted_literal_expression_containing_quotes,
is_interpolated_string_with_quoted_format_spec_and_debug,
};
use crate::string::{StringLikeExtensions, StringNormalizer, StringQuotes, normalize_string};
/// Formats any implicitly concatenated string. This could be any valid combination
/// of string, bytes or f-string literals.
/// of string, bytes, f-string, or t-string literals.
pub(crate) struct FormatImplicitConcatenatedString<'a> {
string: StringLike<'a>,
}
@ -98,6 +101,7 @@ impl Format<PyFormatContext<'_>> for FormatImplicitConcatenatedStringExpanded<'_
StringLikePart::String(part) => part.format().fmt(f),
StringLikePart::Bytes(bytes_literal) => bytes_literal.format().fmt(f),
StringLikePart::FString(part) => part.format().fmt(f),
StringLikePart::TString(part) => part.format().fmt(f),
});
let part_comments = comments.leading_dangling_trailing(part);
@ -138,7 +142,7 @@ impl<'a> FormatImplicitConcatenatedStringFlat<'a> {
let first_part = string.parts().next()?;
// The string is either a regular string, f-string, or bytes string.
// The string is either a regular string, f-string, t-string, or bytes string.
let normalizer = StringNormalizer::from_context(context);
// Some if a part requires preserving its quotes.
@ -164,9 +168,34 @@ impl<'a> FormatImplicitConcatenatedStringFlat<'a> {
return None;
}
if let StringLikePart::FString(fstring) = part {
if context.options().target_version().supports_pep_701() {
if is_fstring_with_quoted_format_spec_and_debug(fstring, context) {
match part {
StringLikePart::FString(fstring) => {
if matches!(string, StringLike::TString(_)) {
// Don't concatenate t-strings and f-strings
return None;
}
if context.options().target_version().supports_pep_701() {
if is_interpolated_string_with_quoted_format_spec_and_debug(
&fstring.elements,
fstring.flags.into(),
context,
) {
if preserve_quotes_requirement
.is_some_and(|quote| quote != part.flags().quote_style())
{
return None;
}
preserve_quotes_requirement = Some(part.flags().quote_style());
}
}
// Avoid invalid syntax for pre Python 312:
// * When joining parts that have debug expressions with quotes: `f"{10 + len('bar')=}" f'{10 + len("bar")=}'
// * When joining parts that contain triple quoted strings with quotes: `f"{'''test ' '''}" f'{"""other " """}'`
else if is_fstring_with_quoted_debug_expression(fstring, context)
|| is_fstring_with_triple_quoted_literal_expression_containing_quotes(
fstring, context,
)
{
if preserve_quotes_requirement
.is_some_and(|quote| quote != part.flags().quote_style())
{
@ -175,21 +204,21 @@ impl<'a> FormatImplicitConcatenatedStringFlat<'a> {
preserve_quotes_requirement = Some(part.flags().quote_style());
}
}
// Avoid invalid syntax for pre Python 312:
// * When joining parts that have debug expressions with quotes: `f"{10 + len('bar')=}" f'{10 + len("bar")=}'
// * When joining parts that contain triple quoted strings with quotes: `f"{'''test ' '''}" f'{"""other " """}'`
else if is_fstring_with_quoted_debug_expression(fstring, context)
|| is_fstring_with_triple_quoted_literal_expression_containing_quotes(
fstring, context,
)
{
if preserve_quotes_requirement
.is_some_and(|quote| quote != part.flags().quote_style())
{
return None;
StringLikePart::TString(tstring) => {
if is_interpolated_string_with_quoted_format_spec_and_debug(
&tstring.elements,
tstring.flags.into(),
context,
) {
if preserve_quotes_requirement
.is_some_and(|quote| quote != part.flags().quote_style())
{
return None;
}
preserve_quotes_requirement = Some(part.flags().quote_style());
}
preserve_quotes_requirement = Some(part.flags().quote_style());
}
StringLikePart::Bytes(_) | StringLikePart::String(_) => {}
}
}
@ -203,6 +232,7 @@ impl<'a> FormatImplicitConcatenatedStringFlat<'a> {
StringLike::String(_) => AnyStringPrefix::Regular(StringLiteralPrefix::Empty),
StringLike::Bytes(_) => AnyStringPrefix::Bytes(ByteStringPrefix::Regular),
StringLike::FString(_) => AnyStringPrefix::Format(FStringPrefix::Regular),
StringLike::TString(_) => AnyStringPrefix::Template(TStringPrefix::Regular),
};
let quote = if let Some(quote) = preserve_quotes_requirement {
@ -287,7 +317,7 @@ impl Format<PyFormatContext<'_>> for FormatImplicitConcatenatedStringFlat<'_> {
FormatLiteralContent {
range: part.content_range(),
flags: self.flags,
is_fstring: false,
is_interpolated_string: false,
trim_start: first_non_empty && self.docstring,
trim_end: self.docstring && parts.peek().is_none(),
}
@ -300,28 +330,32 @@ impl Format<PyFormatContext<'_>> for FormatImplicitConcatenatedStringFlat<'_> {
}
}
StringLikePart::FString(f_string) => {
for element in &f_string.elements {
StringLikePart::FString(FString { elements, .. })
| StringLikePart::TString(TString { elements, .. }) => {
for element in elements {
match element {
FStringElement::Literal(literal) => {
InterpolatedStringElement::Literal(literal) => {
FormatLiteralContent {
range: literal.range(),
flags: self.flags,
is_fstring: true,
is_interpolated_string: true,
trim_end: false,
trim_start: false,
}
.fmt(f)?;
}
// Formatting the expression here and in the expanded version is safe **only**
// because we assert that the f-string never contains any comments.
FStringElement::Expression(expression) => {
let context = FStringContext::new(
// because we assert that the f/t-string never contains any comments.
InterpolatedStringElement::Interpolation(expression) => {
let context = InterpolatedStringContext::new(
self.flags,
FStringLayout::from_f_string(f_string, f.context().source()),
InterpolatedStringLayout::from_interpolated_string_elements(
elements,
f.context().source(),
),
);
FormatFStringExpressionElement::new(expression, context).fmt(f)?;
FormatInterpolatedElement::new(expression, context).fmt(f)?;
}
}
}
@ -336,7 +370,7 @@ impl Format<PyFormatContext<'_>> for FormatImplicitConcatenatedStringFlat<'_> {
struct FormatLiteralContent {
range: TextRange,
flags: AnyStringFlags,
is_fstring: bool,
is_interpolated_string: bool,
trim_start: bool,
trim_end: bool,
}
@ -348,7 +382,7 @@ impl Format<PyFormatContext<'_>> for FormatLiteralContent {
content,
0,
self.flags,
self.flags.is_f_string() && !self.is_fstring,
self.flags.is_interpolated_string() && !self.is_interpolated_string,
);
// Trim the start and end of the string if it's the first or last part of a docstring.

View file

@ -85,57 +85,55 @@ pub(crate) trait StringLikeExtensions {
impl StringLikeExtensions for ast::StringLike<'_> {
fn is_multiline(&self, context: &PyFormatContext) -> bool {
// Helper for f-string and t-string parts
fn contains_line_break_or_comments(
elements: &ast::InterpolatedStringElements,
context: &PyFormatContext,
triple_quotes: TripleQuotes,
) -> bool {
elements.iter().any(|element| match element {
ast::InterpolatedStringElement::Literal(literal) => {
triple_quotes.is_yes() && context.source().contains_line_break(literal.range())
}
ast::InterpolatedStringElement::Interpolation(expression) => {
// Expressions containing comments can't be joined.
//
// Format specifiers needs to be checked as well. For example, the
// following should be considered multiline because the literal
// part of the format specifier contains a newline at the end
// (`.3f\n`):
//
// ```py
// x = f"hello {a + b + c + d:.3f
// } world"
// ```
context.comments().contains_comments(expression.into())
|| expression.format_spec.as_deref().is_some_and(|spec| {
contains_line_break_or_comments(&spec.elements, context, triple_quotes)
})
|| expression.debug_text.as_ref().is_some_and(|debug_text| {
memchr2(b'\n', b'\r', debug_text.leading.as_bytes()).is_some()
|| memchr2(b'\n', b'\r', debug_text.trailing.as_bytes()).is_some()
})
}
})
}
self.parts().any(|part| match part {
StringLikePart::String(_) | StringLikePart::Bytes(_) => {
part.flags().is_triple_quoted()
&& context.source().contains_line_break(part.range())
}
StringLikePart::FString(f_string) => {
fn contains_line_break_or_comments(
elements: &ast::FStringElements,
context: &PyFormatContext,
triple_quotes: TripleQuotes,
) -> bool {
elements.iter().any(|element| match element {
ast::FStringElement::Literal(literal) => {
triple_quotes.is_yes()
&& context.source().contains_line_break(literal.range())
}
ast::FStringElement::Expression(expression) => {
// Expressions containing comments can't be joined.
//
// Format specifiers needs to be checked as well. For example, the
// following should be considered multiline because the literal
// part of the format specifier contains a newline at the end
// (`.3f\n`):
//
// ```py
// x = f"hello {a + b + c + d:.3f
// } world"
// ```
context.comments().contains_comments(expression.into())
|| expression.format_spec.as_deref().is_some_and(|spec| {
contains_line_break_or_comments(
&spec.elements,
context,
triple_quotes,
)
})
|| expression.debug_text.as_ref().is_some_and(|debug_text| {
memchr2(b'\n', b'\r', debug_text.leading.as_bytes()).is_some()
|| memchr2(b'\n', b'\r', debug_text.trailing.as_bytes())
.is_some()
})
}
})
}
contains_line_break_or_comments(
&f_string.elements,
context,
f_string.flags.triple_quotes(),
)
}
StringLikePart::FString(f_string) => contains_line_break_or_comments(
&f_string.elements,
context,
f_string.flags.triple_quotes(),
),
StringLikePart::TString(t_string) => contains_line_break_or_comments(
&t_string.elements,
context,
t_string.flags.triple_quotes(),
),
})
}
}

View file

@ -5,16 +5,15 @@ use std::iter::FusedIterator;
use ruff_formatter::FormatContext;
use ruff_python_ast::visitor::source_order::SourceOrderVisitor;
use ruff_python_ast::{
AnyStringFlags, BytesLiteral, FString, FStringElement, FStringElements, FStringFlags,
AnyStringFlags, BytesLiteral, FString, InterpolatedStringElement, InterpolatedStringElements,
StringFlags, StringLikePart, StringLiteral,
str::{Quote, TripleQuotes},
};
use ruff_text_size::{Ranged, TextRange, TextSlice};
use crate::QuoteStyle;
use crate::context::FStringState;
use crate::context::InterpolatedStringState;
use crate::prelude::*;
use crate::string::StringQuotes;
use crate::string::{Quote, StringQuotes, TripleQuotes};
pub(crate) struct StringNormalizer<'a, 'src> {
preferred_quote_style: Option<QuoteStyle>,
@ -47,11 +46,11 @@ impl<'a, 'src> StringNormalizer<'a, 'src> {
.unwrap_or(self.context.options().quote_style());
let supports_pep_701 = self.context.options().target_version().supports_pep_701();
// 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()
// For f-strings and t-strings prefer alternating the quotes unless The outer string is triple quoted and the inner isn't.
if let InterpolatedStringState::InsideInterpolatedElement(parent_context) =
self.context.interpolated_string_state()
{
let parent_flags = parent_context.f_string().flags();
let parent_flags = parent_context.interpolated_string().flags();
if !parent_flags.is_triple_quoted() || string.flags().is_triple_quoted() {
// This logic is even necessary when using preserve and the target python version doesn't support PEP701 because
// we might end up joining two f-strings that have different quote styles, in which case we need to alternate the quotes
@ -67,33 +66,49 @@ impl<'a, 'src> StringNormalizer<'a, 'src> {
return QuoteStyle::Preserve;
}
// There are cases where it is necessary to preserve the quotes to prevent an invalid f-string.
if let StringLikePart::FString(fstring) = string {
// 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 !supports_pep_701 {
// 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;
// There are cases where it is necessary to preserve the quotes to prevent an invalid f-string or t-string.
match string {
StringLikePart::FString(fstring) => {
// 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 !supports_pep_701 {
// 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;
}
}
// 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,
// An f-string expression element contains a debug text and the corresponding
// format specifier has a literal element with a quote character.
if is_interpolated_string_with_quoted_format_spec_and_debug(
&fstring.elements,
fstring.flags.into(),
self.context,
) {
return QuoteStyle::Preserve;
}
}
// An f-string expression element contains a debug text and the corresponding
// format specifier has a literal element with a quote character.
if is_fstring_with_quoted_format_spec_and_debug(fstring, self.context) {
return QuoteStyle::Preserve;
StringLikePart::TString(tstring) => {
if is_interpolated_string_with_quoted_format_spec_and_debug(
&tstring.elements,
tstring.flags.into(),
self.context,
) {
return QuoteStyle::Preserve;
}
}
_ => {}
}
// Per PEP 8, always prefer double quotes for triple-quoted strings.
@ -172,7 +187,7 @@ 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 metadata = if string.is_fstring() {
let metadata = if string.is_interpolated_string() {
QuoteMetadata::from_part(string, self.context, preferred_quote)
} else {
QuoteMetadata::from_str(
@ -262,9 +277,19 @@ impl QuoteMetadata {
StringLikePart::FString(fstring) => {
let metadata = QuoteMetadata::from_str("", part.flags(), preferred_quote);
metadata.merge_fstring_elements(
metadata.merge_interpolated_string_elements(
&fstring.elements,
fstring.flags,
fstring.flags.into(),
context,
preferred_quote,
)
}
StringLikePart::TString(tstring) => {
let metadata = QuoteMetadata::from_str("", part.flags(), preferred_quote);
metadata.merge_interpolated_string_elements(
&tstring.elements,
tstring.flags.into(),
context,
preferred_quote,
)
@ -369,7 +394,7 @@ impl QuoteMetadata {
})
}
/// For f-strings, only consider the quotes inside string-literals but ignore
/// For f-strings and t-strings, only consider the quotes inside string-literals but ignore
/// quotes inside expressions (except inside the format spec). 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
@ -377,10 +402,10 @@ impl QuoteMetadata {
/// ```python
/// f"{'escaping a quote like this \" is a syntax error pre 312'}"
/// ```
fn merge_fstring_elements(
fn merge_interpolated_string_elements(
self,
elements: &FStringElements,
flags: FStringFlags,
elements: &InterpolatedStringElements,
flags: AnyStringFlags,
context: &PyFormatContext,
preferred_quote: Quote,
) -> Self {
@ -388,19 +413,19 @@ impl QuoteMetadata {
for element in elements {
match element {
FStringElement::Literal(literal) => {
InterpolatedStringElement::Literal(literal) => {
merged = merged
.merge(&QuoteMetadata::from_str(
context.source().slice(literal),
flags.into(),
flags,
preferred_quote,
))
.expect("Merge to succeed because all parts have the same flags");
}
FStringElement::Expression(expression) => {
InterpolatedStringElement::Interpolation(expression) => {
if let Some(spec) = expression.format_spec.as_deref() {
if expression.debug_text.is_none() {
merged = merged.merge_fstring_elements(
merged = merged.merge_interpolated_string_elements(
&spec.elements,
flags,
context,
@ -879,7 +904,7 @@ pub(super) fn is_fstring_with_quoted_debug_expression(
fstring: &FString,
context: &PyFormatContext,
) -> bool {
fstring.elements.expressions().any(|expression| {
fstring.elements.interpolations().any(|expression| {
if expression.debug_text.is_some() {
let content = context.source().slice(expression);
contains_opposite_quote(content, fstring.flags.into())
@ -889,58 +914,6 @@ pub(super) fn is_fstring_with_quoted_debug_expression(
})
}
/// Returns `true` if `string` has any f-string expression element (direct or nested) with a debug expression and a format spec
/// that contains the opposite quote. It's important to preserve the quote style for those f-strings
/// because changing the quote style would result in invalid syntax.
///
/// ```python
/// f'{1=: "abcd \'\'}'
/// f'{x=:a{y:"abcd"}}'
/// f'{x=:a{y:{z:"abcd"}}}'
/// ```
pub(super) fn is_fstring_with_quoted_format_spec_and_debug(
fstring: &FString,
context: &PyFormatContext,
) -> bool {
fn has_format_spec_with_opposite_quote(
elements: &FStringElements,
flags: FStringFlags,
context: &PyFormatContext,
in_debug: bool,
) -> bool {
elements.iter().any(|element| match element {
FStringElement::Literal(literal) => {
let content = context.source().slice(literal);
in_debug && contains_opposite_quote(content, flags.into())
}
FStringElement::Expression(expression) => {
expression.format_spec.as_deref().is_some_and(|spec| {
has_format_spec_with_opposite_quote(
&spec.elements,
flags,
context,
in_debug || expression.debug_text.is_some(),
)
})
}
})
}
fstring.elements.expressions().any(|expression| {
if let Some(spec) = expression.format_spec.as_deref() {
return has_format_spec_with_opposite_quote(
&spec.elements,
fstring.flags,
context,
expression.debug_text.is_some(),
);
}
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.
///
@ -980,6 +953,17 @@ pub(super) fn is_fstring_with_triple_quoted_literal_expression_containing_quotes
}
}
contains_quotes
}
StringLikePart::TString(tstring) => {
let mut contains_quotes = false;
for literal in tstring.elements.literals() {
if self.contains_quote(literal.range(), tstring.flags.into()) {
contains_quotes = true;
break;
}
}
contains_quotes
}
};
@ -1018,6 +1002,59 @@ pub(super) fn is_fstring_with_triple_quoted_literal_expression_containing_quotes
visitor.found
}
/// Returns `true` if `string` has any f/t-string interpolation element (direct or nested) with a debug expression and a format spec
/// that contains the opposite quote. It's important to preserve the quote style for those f/t-strings
/// because changing the quote style would result in invalid syntax.
///
/// ```python
/// t'{1=: "abcd \'\'}'
/// t'{x=:a{y:"abcd"}}'
/// t'{x=:a{y:{z:"abcd"}}}'
/// ```
pub(super) fn is_interpolated_string_with_quoted_format_spec_and_debug(
elements: &InterpolatedStringElements,
flags: AnyStringFlags,
context: &PyFormatContext,
) -> bool {
fn has_format_spec_with_opposite_quote(
elements: &InterpolatedStringElements,
flags: AnyStringFlags,
context: &PyFormatContext,
in_debug: bool,
) -> bool {
elements.iter().any(|element| match element {
InterpolatedStringElement::Literal(literal) => {
let content = context.source().slice(literal);
in_debug && contains_opposite_quote(content, flags)
}
InterpolatedStringElement::Interpolation(expression) => {
expression.format_spec.as_deref().is_some_and(|spec| {
has_format_spec_with_opposite_quote(
&spec.elements,
flags,
context,
in_debug || expression.debug_text.is_some(),
)
})
}
})
}
elements.interpolations().any(|expression| {
if let Some(spec) = expression.format_spec.as_deref() {
return has_format_spec_with_opposite_quote(
&spec.elements,
flags,
context,
expression.debug_text.is_some(),
);
}
false
})
}
fn contains_opposite_quote(content: &str, flags: AnyStringFlags) -> bool {
if flags.is_triple_quoted() {
match flags.quote_style() {