Skip to content

Commit

Permalink
Avoid multiline expression if format specifier is present (#11123)
Browse files Browse the repository at this point in the history
## Summary

This PR fixes the bug where the formatter would format an f-string and
could potentially change the AST.

For a triple-quoted f-string, the element can't be formatted into
multiline if it has a format specifier because otherwise the newline
would be treated as part of the format specifier.

Given the following f-string:
```python
f"""aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbb ccccccccccc {
    variable:.3f} ddddddddddddddd eeeeeeee"""
```

The formatter sees that the f-string is already multiline so it assumes
that it can contain line breaks i.e., broken into multiple lines. But,
in this specific case we can't format it as:

```python
f"""aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbb ccccccccccc {
    variable:.3f
} ddddddddddddddd eeeeeeee"""
```
                     
Because the format specifier string would become ".3f\n", which is not
the original string (`.3f`).

If the original source code already contained a newline, they'll be
preserved. For example:
```python
f"""aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbb ccccccccccc {
    variable:.3f
} ddddddddddddddd eeeeeeee"""
```

The above will be formatted as:
```py
f"""aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbb ccccccccccc {variable:.3f
} ddddddddddddddd eeeeeeee"""
```

Note that the newline after `.3f` is part of the format specifier which
needs to be preserved.
The Python version is irrelevant in this case.

fixes: #10040 

## Test Plan

Add some test cases to verify this behavior.
  • Loading branch information
dhruvmanila authored Apr 26, 2024
1 parent 16a1f3c commit 77a72ec
Show file tree
Hide file tree
Showing 7 changed files with 220 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@
{'x': 1, 'y': 2} }"
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
# any whitespace at the end
x = f"aaaaa { {'aaaaaa', 'bbbbbbb', 'ccccccccc'}!s} bbbbbb"
x = f"aaaaa { {'aaaaaa', 'bbbbbbb', 'ccccccccc'}:.3f} bbbbbb"

# But, in this case, we would split the expression itself because it exceeds the line
# length limit so we need not add the extra space.
Expand Down Expand Up @@ -207,6 +211,29 @@
}" # comment 19
# comment 20

# Single-quoted f-strings with a format specificer can be multiline
f"aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbb ccccccccccc {
variable:.3f} ddddddddddddddd eeeeeeee"

# But, if it's triple-quoted then we can't or the format specificer will have a
# trailing newline
f"""aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbb ccccccccccc {
variable:.3f} ddddddddddddddd eeeeeeee"""

# But, we can break the ones which don't have a format specifier
f"""fooooooooooooooooooo barrrrrrrrrrrrrrrrrrr {
xxxxxxxxxxxxxxx:.3f} aaaaaaaaaaaaaaaaa { xxxxxxxxxxxxxxxxxxxx } bbbbbbbbbbbb"""

# Throw in a random comment in it but surpise, this is not a comment but just a text
# which is part of the format specifier
aaaaaaaaaaa = f"""asaaaaaaaaaaaaaaaa {
aaaaaaaaaaaa + bbbbbbbbbbbb + ccccccccccccccc + dddddddd:.3f
# comment
} cccccccccc"""
aaaaaaaaaaa = f"""asaaaaaaaaaaaaaaaa {
aaaaaaaaaaaa + bbbbbbbbbbbb + ccccccccccccccc + dddddddd:.3f
# comment} cccccccccc"""

# Conversion flags
#
# This is not a valid Python code because of the additional whitespace between the `!`
Expand Down
2 changes: 1 addition & 1 deletion crates/ruff_python_formatter/src/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ impl<'fmt, 'ast, 'buf> JoinCommaSeparatedBuilder<'fmt, 'ast, 'buf> {
if let FStringState::InsideExpressionElement(context) =
self.fmt.context().f_string_state()
{
if context.layout().is_flat() {
if !context.can_contain_line_breaks() {
return Ok(());
}
}
Expand Down
6 changes: 3 additions & 3 deletions crates/ruff_python_formatter/src/context.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::comments::Comments;
use crate::other::f_string::FStringContext;
use crate::other::f_string_element::FStringExpressionElementContext;
use crate::PyFormatOptions;
use ruff_formatter::{Buffer, FormatContext, GroupId, IndentWidth, SourceCode};
use ruff_python_ast::str::Quote;
Expand Down Expand Up @@ -128,13 +128,13 @@ impl Debug for PyFormatContext<'_> {
}
}

#[derive(Copy, Clone, Debug, Default)]
#[derive(Clone, Copy, Debug, Default)]
pub(crate) enum FStringState {
/// 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(FStringContext),
InsideExpressionElement(FStringExpressionElementContext),
/// The formatter is outside an f-string.
#[default]
Outside,
Expand Down
4 changes: 2 additions & 2 deletions crates/ruff_python_formatter/src/other/f_string.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ impl FStringLayout {
}
}

pub(crate) const fn is_flat(self) -> bool {
matches!(self, Self::Flat)
pub(crate) const fn is_multiline(self) -> bool {
matches!(self, FStringLayout::Multiline)
}
}
83 changes: 66 additions & 17 deletions crates/ruff_python_formatter/src/other/f_string_element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,63 @@ impl Format<PyFormatContext<'_>> for FormatFStringLiteralElement<'_> {
}
}

/// Context representing an f-string expression element.
#[derive(Clone, Copy, Debug)]
pub(crate) struct FStringExpressionElementContext {
/// The context of the parent f-string containing this expression element.
parent_context: FStringContext,
/// 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 {
self.parent_context
}

/// Returns `true` if the expression element can contain line breaks.
pub(crate) fn can_contain_line_breaks(self) -> bool {
self.parent_context.layout().is_multiline()
// For a triple-quoted f-string, the element can't be formatted into multiline if it
// has a format specifier because otherwise the newline would be treated as part of the
// format specifier.
//
// Given the following f-string:
// ```python
// f"""aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbb ccccccccccc {variable:.3f} ddddddddddddddd eeeeeeee"""
// ```
//
// We can't format it as:
// ```python
// f"""aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbb ccccccccccc {
// variable:.3f
// } ddddddddddddddd eeeeeeee"""
// ```
//
// Here, the format specifier string would become ".3f\n", which is not what we want.
// But, if the original source code already contained a newline, they'll be preserved.
//
// The Python version is irrelevant in this case.
&& !(self.parent_context.kind().is_triple_quoted() && self.has_format_spec)
}
}

/// Formats an f-string expression element.
pub(crate) struct FormatFStringExpressionElement<'a> {
element: &'a FStringExpressionElement,
context: FStringContext,
context: FStringExpressionElementContext,
}

impl<'a> FormatFStringExpressionElement<'a> {
pub(crate) fn new(element: &'a FStringExpressionElement, context: FStringContext) -> Self {
Self { element, context }
Self {
element,
context: FStringExpressionElementContext {
parent_context: context,
has_format_spec: element.format_spec.is_some(),
},
}
}
}

Expand Down Expand Up @@ -153,10 +201,10 @@ impl Format<PyFormatContext<'_>> for FormatFStringExpressionElement<'_> {
// added to maintain consistency.
Expr::Dict(_) | Expr::DictComp(_) | Expr::Set(_) | Expr::SetComp(_) => {
Some(format_with(|f| {
if self.context.layout().is_flat() {
space().fmt(f)
} else {
if self.context.can_contain_line_breaks() {
soft_line_break_or_space().fmt(f)
} else {
space().fmt(f)
}
}))
}
Expand All @@ -183,12 +231,9 @@ impl Format<PyFormatContext<'_>> for FormatFStringExpressionElement<'_> {
token(":").fmt(f)?;

f.join()
.entries(
format_spec
.elements
.iter()
.map(|element| FormatFStringElement::new(element, self.context)),
)
.entries(format_spec.elements.iter().map(|element| {
FormatFStringElement::new(element, self.context.f_string())
}))
.finish()?;

// These trailing comments can only occur if the format specifier is
Expand All @@ -205,7 +250,11 @@ impl Format<PyFormatContext<'_>> for FormatFStringExpressionElement<'_> {
trailing_comments(comments.trailing(self.element)).fmt(f)?;
}

bracket_spacing.fmt(f)
if conversion.is_none() && format_spec.is_none() {
bracket_spacing.fmt(f)?;
}

Ok(())
});

let open_parenthesis_comments = if dangling_item_comments.is_empty() {
Expand All @@ -219,16 +268,16 @@ impl Format<PyFormatContext<'_>> for FormatFStringExpressionElement<'_> {
{
let mut f = WithNodeLevel::new(NodeLevel::ParenthesizedExpression, f);

if self.context.layout().is_flat() {
let mut buffer = RemoveSoftLinesBuffer::new(&mut *f);

write!(buffer, [open_parenthesis_comments, item])?;
} else {
if self.context.can_contain_line_breaks() {
group(&format_args![
open_parenthesis_comments,
soft_block_indent(&item)
])
.fmt(&mut f)?;
} else {
let mut buffer = RemoveSoftLinesBuffer::new(&mut *f);

write!(buffer, [open_parenthesis_comments, item])?;
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/ruff_python_formatter/src/string/normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ impl StringNormalizer {
// 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.
if (context.kind().is_triple_quoted() && !string.kind().is_triple_quoted())
if (context.f_string().kind().is_triple_quoted() && !string.kind().is_triple_quoted())
|| self.target_version.supports_pep_701()
{
self.quoting
Expand Down
Loading

0 comments on commit 77a72ec

Please sign in to comment.