Skip to content

Commit 07468f8

Browse files
authored
format ExprJoinedStr (#5932)
1 parent ba990b6 commit 07468f8

18 files changed

+221
-747
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
(
2+
f'{one}'
3+
f'{two}'
4+
)
5+
6+
7+
rf"Not-so-tricky \"quote"

crates/ruff_python_formatter/src/expression/expr_constant.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ use ruff_python_ast::str::is_implicit_concatenation;
77

88
use crate::expression::number::{FormatComplex, FormatFloat, FormatInt};
99
use crate::expression::parentheses::{NeedsParentheses, OptionalParentheses};
10-
use crate::expression::string::{FormatString, StringLayout, StringPrefix, StringQuotes};
10+
use crate::expression::string::{
11+
AnyString, FormatString, StringLayout, StringPrefix, StringQuotes,
12+
};
1113
use crate::prelude::*;
1214
use crate::FormatNodeRule;
1315

@@ -56,7 +58,9 @@ impl FormatNodeRule<ExprConstant> for FormatExprConstant {
5658
ExprConstantLayout::Default => StringLayout::Default,
5759
ExprConstantLayout::String(layout) => layout,
5860
};
59-
FormatString::new(item).with_layout(string_layout).fmt(f)
61+
FormatString::new(&AnyString::Constant(item))
62+
.with_layout(string_layout)
63+
.fmt(f)
6064
}
6165
}
6266
}

crates/ruff_python_formatter/src/expression/expr_joined_str.rs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
1+
use super::string::{AnyString, FormatString};
12
use crate::context::PyFormatContext;
23
use crate::expression::parentheses::{NeedsParentheses, OptionalParentheses};
3-
use crate::{not_yet_implemented_custom_text, FormatNodeRule, PyFormatter};
4-
use ruff_formatter::{write, Buffer, FormatResult};
4+
use crate::prelude::*;
5+
use crate::{FormatNodeRule, PyFormatter};
6+
use ruff_formatter::FormatResult;
57
use ruff_python_ast::node::AnyNodeRef;
68
use ruff_python_ast::ExprJoinedStr;
79

810
#[derive(Default)]
911
pub struct FormatExprJoinedStr;
1012

1113
impl FormatNodeRule<ExprJoinedStr> for FormatExprJoinedStr {
12-
fn fmt_fields(&self, _item: &ExprJoinedStr, f: &mut PyFormatter) -> FormatResult<()> {
13-
write!(
14-
f,
15-
[not_yet_implemented_custom_text(
16-
r#"f"NOT_YET_IMPLEMENTED_ExprJoinedStr""#
17-
)]
18-
)
14+
fn fmt_fields(&self, item: &ExprJoinedStr, f: &mut PyFormatter) -> FormatResult<()> {
15+
FormatString::new(&AnyString::JoinedStr(item)).fmt(f)
1916
}
2017
}
2118

crates/ruff_python_formatter/src/expression/string.rs

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
use std::borrow::Cow;
22

33
use bitflags::bitflags;
4-
use ruff_python_ast::{ExprConstant, Ranged};
4+
use ruff_python_ast::node::AnyNodeRef;
5+
use ruff_python_ast::{self as ast, ExprConstant, ExprJoinedStr, Ranged};
56
use ruff_python_parser::lexer::{lex_starts_at, LexicalError, LexicalErrorType};
67
use ruff_python_parser::{Mode, Tok};
8+
use ruff_source_file::Locator;
79
use ruff_text_size::{TextLen, TextRange, TextSize};
810

911
use ruff_formatter::{format_args, write, FormatError};
@@ -13,11 +15,62 @@ use crate::comments::{leading_comments, trailing_comments};
1315
use crate::expression::parentheses::{
1416
in_parentheses_only_group, in_parentheses_only_soft_line_break_or_space,
1517
};
18+
use crate::expression::Expr;
1619
use crate::prelude::*;
1720
use crate::QuoteStyle;
1821

22+
#[derive(Copy, Clone)]
23+
enum Quoting {
24+
CanChange,
25+
Preserve,
26+
}
27+
28+
pub(super) enum AnyString<'a> {
29+
Constant(&'a ExprConstant),
30+
JoinedStr(&'a ExprJoinedStr),
31+
}
32+
33+
impl<'a> AnyString<'a> {
34+
fn quoting(&self, locator: &Locator) -> Quoting {
35+
match self {
36+
Self::Constant(_) => Quoting::CanChange,
37+
Self::JoinedStr(joined_str) => {
38+
if joined_str.values.iter().any(|value| match value {
39+
Expr::FormattedValue(ast::ExprFormattedValue { range, .. }) => {
40+
let string_content = locator.slice(*range);
41+
string_content.contains(['"', '\''])
42+
}
43+
_ => false,
44+
}) {
45+
Quoting::Preserve
46+
} else {
47+
Quoting::CanChange
48+
}
49+
}
50+
}
51+
}
52+
}
53+
54+
impl Ranged for AnyString<'_> {
55+
fn range(&self) -> TextRange {
56+
match self {
57+
Self::Constant(expr) => expr.range(),
58+
Self::JoinedStr(expr) => expr.range(),
59+
}
60+
}
61+
}
62+
63+
impl<'a> From<&AnyString<'a>> for AnyNodeRef<'a> {
64+
fn from(value: &AnyString<'a>) -> Self {
65+
match value {
66+
AnyString::Constant(expr) => AnyNodeRef::ExprConstant(expr),
67+
AnyString::JoinedStr(expr) => AnyNodeRef::ExprJoinedStr(expr),
68+
}
69+
}
70+
}
71+
1972
pub(super) struct FormatString<'a> {
20-
constant: &'a ExprConstant,
73+
string: &'a AnyString<'a>,
2174
layout: StringLayout,
2275
}
2376

@@ -30,10 +83,12 @@ pub enum StringLayout {
3083
}
3184

3285
impl<'a> FormatString<'a> {
33-
pub(super) fn new(constant: &'a ExprConstant) -> Self {
34-
debug_assert!(constant.value.is_str() || constant.value.is_bytes());
86+
pub(super) fn new(string: &'a AnyString) -> Self {
87+
if let AnyString::Constant(constant) = string {
88+
debug_assert!(constant.value.is_str() || constant.value.is_bytes());
89+
}
3590
Self {
36-
constant,
91+
string,
3792
layout: StringLayout::Default,
3893
}
3994
}
@@ -48,40 +103,43 @@ impl<'a> Format<PyFormatContext<'_>> for FormatString<'a> {
48103
fn fmt(&self, f: &mut Formatter<PyFormatContext<'_>>) -> FormatResult<()> {
49104
match self.layout {
50105
StringLayout::Default => {
51-
let string_range = self.constant.range();
106+
let string_range = self.string.range();
52107
let string_content = f.context().locator().slice(string_range);
53108

54109
if is_implicit_concatenation(string_content) {
55-
in_parentheses_only_group(&FormatStringContinuation::new(self.constant)).fmt(f)
110+
in_parentheses_only_group(&FormatStringContinuation::new(self.string)).fmt(f)
56111
} else {
57-
FormatStringPart::new(string_range).fmt(f)
112+
FormatStringPart::new(string_range, self.string.quoting(&f.context().locator()))
113+
.fmt(f)
58114
}
59115
}
60116
StringLayout::ImplicitConcatenatedBinaryLeftSide => {
61-
FormatStringContinuation::new(self.constant).fmt(f)
117+
FormatStringContinuation::new(self.string).fmt(f)
62118
}
63119
}
64120
}
65121
}
66122

67123
struct FormatStringContinuation<'a> {
68-
constant: &'a ExprConstant,
124+
string: &'a AnyString<'a>,
69125
}
70126

71127
impl<'a> FormatStringContinuation<'a> {
72-
fn new(constant: &'a ExprConstant) -> Self {
73-
debug_assert!(constant.value.is_str() || constant.value.is_bytes());
74-
Self { constant }
128+
fn new(string: &'a AnyString<'a>) -> Self {
129+
if let AnyString::Constant(constant) = string {
130+
debug_assert!(constant.value.is_str() || constant.value.is_bytes());
131+
}
132+
Self { string }
75133
}
76134
}
77135

78136
impl Format<PyFormatContext<'_>> for FormatStringContinuation<'_> {
79137
fn fmt(&self, f: &mut Formatter<PyFormatContext<'_>>) -> FormatResult<()> {
80138
let comments = f.context().comments().clone();
81139
let locator = f.context().locator();
82-
let mut dangling_comments = comments.dangling_comments(self.constant);
140+
let mut dangling_comments = comments.dangling_comments(self.string);
83141

84-
let string_range = self.constant.range();
142+
let string_range = self.string.range();
85143
let string_content = locator.slice(string_range);
86144

87145
// The AST parses implicit concatenation as a single string.
@@ -155,7 +213,7 @@ impl Format<PyFormatContext<'_>> for FormatStringContinuation<'_> {
155213
joiner.entry(&format_args![
156214
line_suffix_boundary(),
157215
leading_comments(leading_part_comments),
158-
FormatStringPart::new(token_range),
216+
FormatStringPart::new(token_range, self.string.quoting(&locator)),
159217
trailing_comments(trailing_part_comments)
160218
]);
161219

@@ -178,11 +236,15 @@ impl Format<PyFormatContext<'_>> for FormatStringContinuation<'_> {
178236

179237
struct FormatStringPart {
180238
part_range: TextRange,
239+
quoting: Quoting,
181240
}
182241

183242
impl FormatStringPart {
184-
const fn new(range: TextRange) -> Self {
185-
Self { part_range: range }
243+
const fn new(range: TextRange, quoting: Quoting) -> Self {
244+
Self {
245+
part_range: range,
246+
quoting,
247+
}
186248
}
187249
}
188250

@@ -204,10 +266,15 @@ impl Format<PyFormatContext<'_>> for FormatStringPart {
204266

205267
let raw_content = &string_content[relative_raw_content_range];
206268
let is_raw_string = prefix.is_raw_string();
207-
let preferred_quotes = if is_raw_string {
208-
preferred_quotes_raw(raw_content, quotes, f.options().quote_style())
209-
} else {
210-
preferred_quotes(raw_content, quotes, f.options().quote_style())
269+
let preferred_quotes = match self.quoting {
270+
Quoting::Preserve => quotes,
271+
Quoting::CanChange => {
272+
if is_raw_string {
273+
preferred_quotes_raw(raw_content, quotes, f.options().quote_style())
274+
} else {
275+
preferred_quotes(raw_content, quotes, f.options().quote_style())
276+
}
277+
}
211278
};
212279

213280
write!(f, [prefix, preferred_quotes])?;

crates/ruff_python_formatter/tests/snapshots/black_compatibility@miscellaneous__debug_visitor.py.snap

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class DebugVisitor(Visitor[T]):
4444
```diff
4545
--- Black
4646
+++ Ruff
47-
@@ -3,24 +3,29 @@
47+
@@ -3,24 +3,24 @@
4848
tree_depth: int = 0
4949
5050
def visit_default(self, node: LN) -> Iterator[T]:
@@ -53,30 +53,25 @@ class DebugVisitor(Visitor[T]):
5353
if isinstance(node, Node):
5454
_type = type_repr(node.type)
5555
- out(f'{indent}{_type}', fg='yellow')
56-
+ out(f"NOT_YET_IMPLEMENTED_ExprJoinedStr", fg="yellow")
56+
+ out(f"{indent}{_type}", fg="yellow")
5757
self.tree_depth += 1
5858
for child in node.children:
5959
yield from self.visit(child)
6060
6161
self.tree_depth -= 1
6262
- out(f'{indent}/{_type}', fg='yellow', bold=False)
63-
+ out(f"NOT_YET_IMPLEMENTED_ExprJoinedStr", fg="yellow", bold=False)
63+
+ out(f"{indent}/{_type}", fg="yellow", bold=False)
6464
else:
6565
_type = token.tok_name.get(node.type, str(node.type))
6666
- out(f'{indent}{_type}', fg='blue', nl=False)
67-
+ out(f"NOT_YET_IMPLEMENTED_ExprJoinedStr", fg="blue", nl=False)
67+
+ out(f"{indent}{_type}", fg="blue", nl=False)
6868
if node.prefix:
6969
# We don't have to handle prefixes for `Node` objects since
7070
# that delegates to the first child anyway.
7171
- out(f' {node.prefix!r}', fg='green', bold=False, nl=False)
7272
- out(f' {node.value!r}', fg='blue', bold=False)
73-
+ out(
74-
+ f"NOT_YET_IMPLEMENTED_ExprJoinedStr",
75-
+ fg="green",
76-
+ bold=False,
77-
+ nl=False,
78-
+ )
79-
+ out(f"NOT_YET_IMPLEMENTED_ExprJoinedStr", fg="blue", bold=False)
73+
+ out(f" {node.prefix!r}", fg="green", bold=False, nl=False)
74+
+ out(f" {node.value!r}", fg="blue", bold=False)
8075
8176
@classmethod
8277
def show(cls, code: str) -> None:
@@ -93,26 +88,21 @@ class DebugVisitor(Visitor[T]):
9388
indent = " " * (2 * self.tree_depth)
9489
if isinstance(node, Node):
9590
_type = type_repr(node.type)
96-
out(f"NOT_YET_IMPLEMENTED_ExprJoinedStr", fg="yellow")
91+
out(f"{indent}{_type}", fg="yellow")
9792
self.tree_depth += 1
9893
for child in node.children:
9994
yield from self.visit(child)
10095
10196
self.tree_depth -= 1
102-
out(f"NOT_YET_IMPLEMENTED_ExprJoinedStr", fg="yellow", bold=False)
97+
out(f"{indent}/{_type}", fg="yellow", bold=False)
10398
else:
10499
_type = token.tok_name.get(node.type, str(node.type))
105-
out(f"NOT_YET_IMPLEMENTED_ExprJoinedStr", fg="blue", nl=False)
100+
out(f"{indent}{_type}", fg="blue", nl=False)
106101
if node.prefix:
107102
# We don't have to handle prefixes for `Node` objects since
108103
# that delegates to the first child anyway.
109-
out(
110-
f"NOT_YET_IMPLEMENTED_ExprJoinedStr",
111-
fg="green",
112-
bold=False,
113-
nl=False,
114-
)
115-
out(f"NOT_YET_IMPLEMENTED_ExprJoinedStr", fg="blue", bold=False)
104+
out(f" {node.prefix!r}", fg="green", bold=False, nl=False)
105+
out(f" {node.value!r}", fg="blue", bold=False)
116106
117107
@classmethod
118108
def show(cls, code: str) -> None:

crates/ruff_python_formatter/tests/snapshots/black_compatibility@miscellaneous__docstring_preview_no_string_normalization.py.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def do_not_touch_this_prefix3():
2727
2828
def do_not_touch_this_prefix2():
2929
- FR'There was a bug where docstring prefixes would be normalized even with -S.'
30-
+ f"NOT_YET_IMPLEMENTED_ExprJoinedStr"
30+
+ Rf"There was a bug where docstring prefixes would be normalized even with -S."
3131
3232
3333
def do_not_touch_this_prefix3():
@@ -43,7 +43,7 @@ def do_not_touch_this_prefix():
4343
4444
4545
def do_not_touch_this_prefix2():
46-
f"NOT_YET_IMPLEMENTED_ExprJoinedStr"
46+
Rf"There was a bug where docstring prefixes would be normalized even with -S."
4747
4848
4949
def do_not_touch_this_prefix3():

0 commit comments

Comments
 (0)