Skip to content

Commit fab614e

Browse files
committed
Hug multiline-strings preview style
Signed-off-by: Micha Reiser <[email protected]>
1 parent 20af5a7 commit fab614e

File tree

16 files changed

+588
-213
lines changed

16 files changed

+588
-213
lines changed

crates/ruff_formatter/src/printer/mod.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1472,6 +1472,11 @@ impl<'a, 'print> FitsMeasurer<'a, 'print> {
14721472
}
14731473

14741474
fn fits_text(&mut self, text: Text, args: PrintElementArgs) -> Fits {
1475+
fn exceeds_width(fits: &FitsMeasurer, args: PrintElementArgs) -> bool {
1476+
fits.state.line_width > fits.options().line_width.into()
1477+
&& !args.measure_mode().allows_text_overflow()
1478+
}
1479+
14751480
let indent = std::mem::take(&mut self.state.pending_indent);
14761481
self.state.line_width +=
14771482
u32::from(indent.level()) * self.options().indent_width() + u32::from(indent.align());
@@ -1493,7 +1498,13 @@ impl<'a, 'print> FitsMeasurer<'a, 'print> {
14931498
return Fits::No;
14941499
}
14951500
match args.measure_mode() {
1496-
MeasureMode::FirstLine => return Fits::Yes,
1501+
MeasureMode::FirstLine => {
1502+
return if exceeds_width(self, args) {
1503+
Fits::No
1504+
} else {
1505+
Fits::Yes
1506+
};
1507+
}
14971508
MeasureMode::AllLines
14981509
| MeasureMode::AllLinesAllowTextOverflow => {
14991510
self.state.line_width = 0;
@@ -1511,9 +1522,7 @@ impl<'a, 'print> FitsMeasurer<'a, 'print> {
15111522
}
15121523
}
15131524

1514-
if self.state.line_width > self.options().line_width.into()
1515-
&& !args.measure_mode().allows_text_overflow()
1516-
{
1525+
if exceeds_width(self, args) {
15171526
return Fits::No;
15181527
}
15191528

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# This file documents the deviations for formatting multiline strings with black.
2+
3+
# Black hugs the parentheses for `%` usages -> convert to fstring.
4+
# Can get unreadable if the arguments split
5+
# This could be solved by using `best_fitting` to try to format the arguments on a single
6+
# line. Let's consider adding this later.
7+
# ```python
8+
# call(
9+
# 3,
10+
# "dogsay",
11+
# textwrap.dedent(
12+
# """dove
13+
# coo""" % "cowabunga",
14+
# more,
15+
# and_more,
16+
# "aaaaaaa",
17+
# "bbbbbbbbb",
18+
# "cccccccc",
19+
# ),
20+
# )
21+
# ```
22+
call(3, "dogsay", textwrap.dedent("""dove
23+
coo""" % "cowabunga"))
24+
25+
# Black applies the hugging recursively. We don't (consistent with the hugging style).
26+
path.write_text(textwrap.dedent("""\
27+
A triple-quoted string
28+
actually leveraging the textwrap.dedent functionality
29+
that ends in a trailing newline,
30+
representing e.g. file contents.
31+
"""))
32+
33+
34+
35+
# Black avoids parenthesizing the following lambda. We could potentially support
36+
# this by changing `Lambda::needs_parentheses` to return `BestFit` but it causes
37+
# issues when the lambda has comments.
38+
# Let's keep this as a known deviation for now.
39+
generated_readme = lambda project_name: """
40+
{}
41+
42+
<Add content here!>
43+
""".strip().format(project_name)

crates/ruff_python_formatter/src/expression/binary_like.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -394,12 +394,12 @@ impl Format<PyFormatContext<'_>> for BinaryLike<'_> {
394394
f,
395395
[
396396
operand.leading_binary_comments().map(leading_comments),
397-
leading_comments(comments.leading(&string_constant)),
397+
leading_comments(comments.leading(string_constant)),
398398
// Call `FormatStringContinuation` directly to avoid formatting
399399
// the implicitly concatenated string with the enclosing group
400400
// because the group is added by the binary like formatting.
401401
FormatStringContinuation::new(&string_constant),
402-
trailing_comments(comments.trailing(&string_constant)),
402+
trailing_comments(comments.trailing(string_constant)),
403403
operand.trailing_binary_comments().map(trailing_comments),
404404
line_suffix_boundary(),
405405
]
@@ -413,12 +413,12 @@ impl Format<PyFormatContext<'_>> for BinaryLike<'_> {
413413
write!(
414414
f,
415415
[
416-
leading_comments(comments.leading(&string_constant)),
416+
leading_comments(comments.leading(string_constant)),
417417
// Call `FormatStringContinuation` directly to avoid formatting
418418
// the implicitly concatenated string with the enclosing group
419419
// because the group is added by the binary like formatting.
420420
FormatStringContinuation::new(&string_constant),
421-
trailing_comments(comments.trailing(&string_constant)),
421+
trailing_comments(comments.trailing(string_constant)),
422422
]
423423
)?;
424424
}

crates/ruff_python_formatter/src/expression/expr_bin_op.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ use ruff_python_ast::ExprBinOp;
33

44
use crate::comments::SourceComment;
55
use crate::expression::binary_like::BinaryLike;
6-
use crate::expression::expr_string_literal::is_multiline_string;
76
use crate::expression::has_parentheses;
87
use crate::expression::parentheses::{NeedsParentheses, OptionalParentheses};
98
use crate::prelude::*;
9+
use crate::string::AnyString;
1010

1111
#[derive(Default)]
1212
pub struct FormatExprBinOp;
@@ -35,13 +35,13 @@ impl NeedsParentheses for ExprBinOp {
3535
) -> OptionalParentheses {
3636
if parent.is_expr_await() {
3737
OptionalParentheses::Always
38-
} else if let Some(literal_expr) = self.left.as_literal_expr() {
38+
} else if let Some(string) = AnyString::from_expression(&self.left) {
3939
// Multiline strings are guaranteed to never fit, avoid adding unnecessary parentheses
40-
if !literal_expr.is_implicit_concatenated()
41-
&& is_multiline_string(literal_expr.into(), context.source())
40+
if !string.is_implicit_concatenated()
41+
&& string.is_multiline(context.source())
4242
&& has_parentheses(&self.right, context).is_some()
4343
&& !context.comments().has_dangling(self)
44-
&& !context.comments().has(literal_expr)
44+
&& !context.comments().has(string)
4545
&& !context.comments().has(self.right.as_ref())
4646
{
4747
OptionalParentheses::Never

crates/ruff_python_formatter/src/expression/expr_bytes_literal.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ use ruff_python_ast::AnyNodeRef;
22
use ruff_python_ast::ExprBytesLiteral;
33

44
use crate::comments::SourceComment;
5-
use crate::expression::expr_string_literal::is_multiline_string;
65
use crate::expression::parentheses::{
76
in_parentheses_only_group, NeedsParentheses, OptionalParentheses,
87
};
@@ -41,7 +40,7 @@ impl NeedsParentheses for ExprBytesLiteral {
4140
) -> OptionalParentheses {
4241
if self.value.is_implicit_concatenated() {
4342
OptionalParentheses::Multiline
44-
} else if is_multiline_string(self.into(), context.source()) {
43+
} else if AnyString::Bytes(self).is_multiline(context.source()) {
4544
OptionalParentheses::Never
4645
} else {
4746
OptionalParentheses::BestFit

crates/ruff_python_formatter/src/expression/expr_compare.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ use ruff_python_ast::{CmpOp, ExprCompare};
44

55
use crate::comments::SourceComment;
66
use crate::expression::binary_like::BinaryLike;
7-
use crate::expression::expr_string_literal::is_multiline_string;
87
use crate::expression::has_parentheses;
98
use crate::expression::parentheses::{NeedsParentheses, OptionalParentheses};
109
use crate::prelude::*;
10+
use crate::string::AnyString;
1111

1212
#[derive(Default)]
1313
pub struct FormatExprCompare;
@@ -37,11 +37,11 @@ impl NeedsParentheses for ExprCompare {
3737
) -> OptionalParentheses {
3838
if parent.is_expr_await() {
3939
OptionalParentheses::Always
40-
} else if let Some(literal_expr) = self.left.as_literal_expr() {
40+
} else if let Some(string) = AnyString::from_expression(&self.left) {
4141
// Multiline strings are guaranteed to never fit, avoid adding unnecessary parentheses
42-
if !literal_expr.is_implicit_concatenated()
43-
&& is_multiline_string(literal_expr.into(), context.source())
44-
&& !context.comments().has(literal_expr)
42+
if !string.is_implicit_concatenated()
43+
&& string.is_multiline(context.source())
44+
&& !context.comments().has(string)
4545
&& self.comparators.first().is_some_and(|right| {
4646
has_parentheses(right, context).is_some() && !context.comments().has(right)
4747
})

crates/ruff_python_formatter/src/expression/expr_f_string.rs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
use memchr::memchr2;
2-
31
use ruff_python_ast::{AnyNodeRef, ExprFString};
42
use ruff_source_file::Locator;
53
use ruff_text_size::Ranged;
@@ -50,10 +48,10 @@ impl NeedsParentheses for ExprFString {
5048
) -> OptionalParentheses {
5149
if self.value.is_implicit_concatenated() {
5250
OptionalParentheses::Multiline
53-
} else if memchr2(b'\n', b'\r', context.source()[self.range].as_bytes()).is_none() {
54-
OptionalParentheses::BestFit
55-
} else {
51+
} else if AnyString::FString(self).is_multiline(context.source()) {
5652
OptionalParentheses::Never
53+
} else {
54+
OptionalParentheses::BestFit
5755
}
5856
}
5957
}

crates/ruff_python_formatter/src/expression/expr_string_literal.rs

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
use ruff_formatter::FormatRuleWithOptions;
22
use ruff_python_ast::{AnyNodeRef, ExprStringLiteral};
3-
use ruff_text_size::{Ranged, TextLen, TextRange};
43

54
use crate::comments::SourceComment;
65
use crate::expression::parentheses::{
76
in_parentheses_only_group, NeedsParentheses, OptionalParentheses,
87
};
98
use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind};
109
use crate::prelude::*;
11-
use crate::string::{AnyString, FormatStringContinuation, StringPrefix, StringQuotes};
10+
use crate::string::{AnyString, FormatStringContinuation};
1211

1312
#[derive(Default)]
1413
pub struct FormatExprStringLiteral {
@@ -80,24 +79,10 @@ impl NeedsParentheses for ExprStringLiteral {
8079
) -> OptionalParentheses {
8180
if self.value.is_implicit_concatenated() {
8281
OptionalParentheses::Multiline
83-
} else if is_multiline_string(self.into(), context.source()) {
82+
} else if AnyString::String(self).is_multiline(context.source()) {
8483
OptionalParentheses::Never
8584
} else {
8685
OptionalParentheses::BestFit
8786
}
8887
}
8988
}
90-
91-
pub(super) fn is_multiline_string(expr: AnyNodeRef, source: &str) -> bool {
92-
if expr.is_expr_string_literal() || expr.is_expr_bytes_literal() {
93-
let contents = &source[expr.range()];
94-
let prefix = StringPrefix::parse(contents);
95-
let quotes =
96-
StringQuotes::parse(&contents[TextRange::new(prefix.text_len(), contents.text_len())]);
97-
98-
quotes.is_some_and(StringQuotes::is_triple)
99-
&& memchr::memchr2(b'\n', b'\r', contents.as_bytes()).is_some()
100-
} else {
101-
false
102-
}
103-
}

crates/ruff_python_formatter/src/expression/mod.rs

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@ use crate::context::{NodeLevel, WithNodeLevel};
1717
use crate::expression::expr_generator_exp::is_generator_parenthesized;
1818
use crate::expression::expr_tuple::is_tuple_parenthesized;
1919
use crate::expression::parentheses::{
20-
is_expression_parenthesized, optional_parentheses, parenthesized, NeedsParentheses,
21-
OptionalParentheses, Parentheses, Parenthesize,
20+
is_expression_parenthesized, optional_parentheses, parenthesized, HuggingStyle,
21+
NeedsParentheses, OptionalParentheses, Parentheses, Parenthesize,
2222
};
2323
use crate::prelude::*;
24-
use crate::preview::is_hug_parens_with_braces_and_square_brackets_enabled;
24+
use crate::preview::{
25+
is_hug_parens_with_braces_and_square_brackets_enabled, is_multiline_string_handling_enabled,
26+
};
27+
use crate::string::AnyString;
2528

2629
mod binary_like;
2730
pub(crate) mod expr_attribute;
@@ -126,7 +129,7 @@ impl FormatRule<Expr, PyFormatContext<'_>> for FormatExpr {
126129
let node_comments = comments.leading_dangling_trailing(expression);
127130
if !node_comments.has_leading() && !node_comments.has_trailing() {
128131
parenthesized("(", &format_expr, ")")
129-
.with_indent(!is_expression_huggable(expression, f.context()))
132+
.with_hugging(is_expression_huggable(expression, f.context()))
130133
.fmt(f)
131134
} else {
132135
format_with_parentheses_comments(expression, &node_comments, f)
@@ -444,7 +447,7 @@ impl Format<PyFormatContext<'_>> for MaybeParenthesizeExpression<'_> {
444447
OptionalParentheses::Never => match parenthesize {
445448
Parenthesize::IfBreaksOrIfRequired => {
446449
parenthesize_if_expands(&expression.format().with_options(Parentheses::Never))
447-
.with_indent(!is_expression_huggable(expression, f.context()))
450+
.with_indent(is_expression_huggable(expression, f.context()).is_none())
448451
.fmt(f)
449452
}
450453

@@ -1084,7 +1087,7 @@ pub(crate) fn has_own_parentheses(
10841087
}
10851088

10861089
/// Returns `true` if the expression can hug directly to enclosing parentheses, as in Black's
1087-
/// `hug_parens_with_braces_and_square_brackets` preview style behavior.
1090+
/// `hug_parens_with_braces_and_square_brackets` or `multiline_string_handling` preview styles behavior.
10881091
///
10891092
/// For example, in preview style, given:
10901093
/// ```python
@@ -1110,30 +1113,25 @@ pub(crate) fn has_own_parentheses(
11101113
/// ]
11111114
/// )
11121115
/// ```
1113-
pub(crate) fn is_expression_huggable(expr: &Expr, context: &PyFormatContext) -> bool {
1114-
if !is_hug_parens_with_braces_and_square_brackets_enabled(context) {
1115-
return false;
1116-
}
1117-
1116+
pub(crate) fn is_expression_huggable(
1117+
expr: &Expr,
1118+
context: &PyFormatContext,
1119+
) -> Option<HuggingStyle> {
11181120
match expr {
11191121
Expr::Tuple(_)
11201122
| Expr::List(_)
11211123
| Expr::Set(_)
11221124
| Expr::Dict(_)
11231125
| Expr::ListComp(_)
11241126
| Expr::SetComp(_)
1125-
| Expr::DictComp(_) => true,
1126-
1127-
Expr::Starred(ast::ExprStarred { value, .. }) => matches!(
1128-
value.as_ref(),
1129-
Expr::Tuple(_)
1130-
| Expr::List(_)
1131-
| Expr::Set(_)
1132-
| Expr::Dict(_)
1133-
| Expr::ListComp(_)
1134-
| Expr::SetComp(_)
1135-
| Expr::DictComp(_)
1136-
),
1127+
| Expr::DictComp(_) => is_hug_parens_with_braces_and_square_brackets_enabled(context)
1128+
.then_some(HuggingStyle::Always),
1129+
1130+
Expr::Starred(ast::ExprStarred { value, .. }) => is_expression_huggable(value, context),
1131+
1132+
Expr::StringLiteral(string) => is_huggable_string(AnyString::String(string), context),
1133+
Expr::BytesLiteral(bytes) => is_huggable_string(AnyString::Bytes(bytes), context),
1134+
Expr::FString(fstring) => is_huggable_string(AnyString::FString(fstring), context),
11371135

11381136
Expr::BoolOp(_)
11391137
| Expr::NamedExpr(_)
@@ -1147,18 +1145,28 @@ pub(crate) fn is_expression_huggable(expr: &Expr, context: &PyFormatContext) ->
11471145
| Expr::YieldFrom(_)
11481146
| Expr::Compare(_)
11491147
| Expr::Call(_)
1150-
| Expr::FString(_)
11511148
| Expr::Attribute(_)
11521149
| Expr::Subscript(_)
11531150
| Expr::Name(_)
11541151
| Expr::Slice(_)
11551152
| Expr::IpyEscapeCommand(_)
1156-
| Expr::StringLiteral(_)
1157-
| Expr::BytesLiteral(_)
11581153
| Expr::NumberLiteral(_)
11591154
| Expr::BooleanLiteral(_)
11601155
| Expr::NoneLiteral(_)
1161-
| Expr::EllipsisLiteral(_) => false,
1156+
| Expr::EllipsisLiteral(_) => None,
1157+
}
1158+
}
1159+
1160+
/// Returns `true` if `string` is a multiline string that is not implicitly concatenated.
1161+
fn is_huggable_string(string: AnyString, context: &PyFormatContext) -> Option<HuggingStyle> {
1162+
if !is_multiline_string_handling_enabled(context) {
1163+
return None;
1164+
}
1165+
1166+
if !string.is_implicit_concatenated() && string.is_multiline(context.source()) {
1167+
Some(HuggingStyle::IfFirstLineFits)
1168+
} else {
1169+
None
11621170
}
11631171
}
11641172

0 commit comments

Comments
 (0)