Skip to content

Commit 1aad180

Browse files
Don't add chaperone space after escaped quote in triple quote (#17216)
Co-authored-by: Micha Reiser <micha@reiser.io>
1 parent 1a3b737 commit 1aad180

File tree

5 files changed

+307
-11
lines changed

5 files changed

+307
-11
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
def a1():
2+
"""Needs chaperone\\" """
3+
4+
def a2():
5+
"""Needs chaperone\\\ """
6+
7+
def a3():
8+
"""Needs chaperone" """
9+
10+
def a4():
11+
"""Needs chaperone\ """
12+
13+
def a5():
14+
"""Needs chaperone\\\\\ """
15+
16+
def a6():
17+
"""Needs chaperone\"" """
18+
19+
def a7():
20+
"""Doesn't need chaperone\""""
21+
22+
def a8():
23+
"""Doesn't need chaperone\'"""
24+
25+
def a9():
26+
"""Doesn't need chaperone\\\""""
27+
28+
def a10():
29+
"""Doesn't need chaperone\\\'"""
30+
31+
def a11():
32+
"""Doesn't need chaperone; all slashes escaped\\\\"""
33+
34+
def a12():
35+
"""Doesn't need chaperone\\"""
36+
37+
def a12():
38+
"""Doesn't need "chaperone" with contained quotes"""
39+
40+
def a13():
41+
"""Doesn't need chaperone\\\"\"\""""
42+
43+
def a14():
44+
"""Multiline docstring
45+
doesn't need chaperone
46+
"""
47+
48+
def a15():
49+
"""Multiline docstring
50+
doesn't need chaperone\
51+
"""
52+
53+
def a16():
54+
"""Multiline docstring with
55+
odd number of backslashes don't need chaperone\\\
56+
"""
57+
58+
def a17():
59+
"""Multiline docstring with
60+
even number of backslashes don't need chaperone\\\\
61+
"""
62+
63+
def a18():
64+
r"""Raw multiline docstring
65+
doesn't need chaperone\
66+
"""

crates/ruff_python_formatter/src/preview.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,10 @@ pub(crate) const fn is_hug_parens_with_braces_and_square_brackets_enabled(
1313
) -> bool {
1414
context.is_preview()
1515
}
16+
17+
/// Returns `true` if the [`no_chaperone_for_escaped_quote_in_triple_quoted_docstring`](https://github.com/astral-sh/ruff/pull/17216) preview style is enabled.
18+
pub(crate) const fn is_no_chaperone_for_escaped_quote_in_triple_quoted_docstring_enabled(
19+
context: &PyFormatContext,
20+
) -> bool {
21+
context.is_preview()
22+
}

crates/ruff_python_formatter/src/string/docstring.rs

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ use {
1919
ruff_text_size::{Ranged, TextLen, TextRange, TextSize},
2020
};
2121

22+
use super::NormalizedString;
23+
use crate::preview::is_no_chaperone_for_escaped_quote_in_triple_quoted_docstring_enabled;
2224
use crate::string::StringQuotes;
2325
use crate::{prelude::*, DocstringCodeLineWidth, FormatModuleError};
2426

25-
use super::NormalizedString;
26-
2727
/// Format a docstring by trimming whitespace and adjusting the indentation.
2828
///
2929
/// Summary of changes we make:
@@ -168,7 +168,7 @@ pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form
168168
if docstring[first.len()..].trim().is_empty() {
169169
// For `"""\n"""` or other whitespace between the quotes, black keeps a single whitespace,
170170
// but `""""""` doesn't get one inserted.
171-
if needs_chaperone_space(normalized.flags(), trim_end)
171+
if needs_chaperone_space(normalized.flags(), trim_end, f.context())
172172
|| (trim_end.is_empty() && !docstring.is_empty())
173173
{
174174
space().fmt(f)?;
@@ -208,7 +208,7 @@ pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form
208208
let trim_end = docstring
209209
.as_ref()
210210
.trim_end_matches(|c: char| c.is_whitespace() && c != '\n');
211-
if needs_chaperone_space(normalized.flags(), trim_end) {
211+
if needs_chaperone_space(normalized.flags(), trim_end, f.context()) {
212212
space().fmt(f)?;
213213
}
214214

@@ -1596,17 +1596,45 @@ fn docstring_format_source(
15961596
Ok(formatted.print()?)
15971597
}
15981598

1599-
/// If the last line of the docstring is `content" """` or `content\ """`, we need a chaperone space
1600-
/// that avoids `content""""` and `content\"""`. This does only applies to un-escaped backslashes,
1601-
/// so `content\\ """` doesn't need a space while `content\\\ """` does.
1602-
pub(super) fn needs_chaperone_space(flags: AnyStringFlags, trim_end: &str) -> bool {
1603-
if trim_end.chars().rev().take_while(|c| *c == '\\').count() % 2 == 1 {
1604-
true
1599+
/// If the last line of the docstring is `content""""` or `content\"""`, we need a chaperone space
1600+
/// that avoids `content""""` and `content\"""`. This only applies to un-escaped backslashes,
1601+
/// so `content\\"""` doesn't need a space while `content\\\"""` does.
1602+
pub(super) fn needs_chaperone_space(
1603+
flags: AnyStringFlags,
1604+
trim_end: &str,
1605+
context: &PyFormatContext,
1606+
) -> bool {
1607+
if count_consecutive_chars_from_end(trim_end, '\\') % 2 == 1 {
1608+
// Odd backslash count; chaperone avoids escaping closing quotes
1609+
// `"\ "` -> prevent that this becomes `"\"` which escapes the closing quote.
1610+
return true;
1611+
}
1612+
1613+
if is_no_chaperone_for_escaped_quote_in_triple_quoted_docstring_enabled(context) {
1614+
if flags.is_triple_quoted() {
1615+
if let Some(before_quote) = trim_end.strip_suffix(flags.quote_style().as_char()) {
1616+
if count_consecutive_chars_from_end(before_quote, '\\') % 2 == 0 {
1617+
// Even backslash count preceding quote;
1618+
// ```py
1619+
// """a " """
1620+
// """a \\" """
1621+
// ```
1622+
// The chaperon is needed or the triple quoted string "ends" with 4 instead of 3 quotes.
1623+
return true;
1624+
}
1625+
}
1626+
}
1627+
1628+
false
16051629
} else {
16061630
flags.is_triple_quoted() && trim_end.ends_with(flags.quote_style().as_char())
16071631
}
16081632
}
16091633

1634+
fn count_consecutive_chars_from_end(s: &str, target: char) -> usize {
1635+
s.chars().rev().take_while(|c| *c == target).count()
1636+
}
1637+
16101638
#[derive(Copy, Clone, Debug)]
16111639
enum Indentation {
16121640
/// Space only indentation or an empty indentation.

crates/ruff_python_formatter/src/string/implicit.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ impl Format<PyFormatContext<'_>> for FormatLiteralContent {
372372
Cow::Owned(normalized) => text(normalized).fmt(f)?,
373373
}
374374

375-
if self.trim_end && needs_chaperone_space(self.flags, &normalized) {
375+
if self.trim_end && needs_chaperone_space(self.flags, &normalized, f.context()) {
376376
space().fmt(f)?;
377377
}
378378
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
---
2+
source: crates/ruff_python_formatter/tests/fixtures.rs
3+
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/docstring_chaperones.py
4+
---
5+
## Input
6+
```python
7+
def a1():
8+
"""Needs chaperone\\" """
9+
10+
def a2():
11+
"""Needs chaperone\\\ """
12+
13+
def a3():
14+
"""Needs chaperone" """
15+
16+
def a4():
17+
"""Needs chaperone\ """
18+
19+
def a5():
20+
"""Needs chaperone\\\\\ """
21+
22+
def a6():
23+
"""Needs chaperone\"" """
24+
25+
def a7():
26+
"""Doesn't need chaperone\""""
27+
28+
def a8():
29+
"""Doesn't need chaperone\'"""
30+
31+
def a9():
32+
"""Doesn't need chaperone\\\""""
33+
34+
def a10():
35+
"""Doesn't need chaperone\\\'"""
36+
37+
def a11():
38+
"""Doesn't need chaperone; all slashes escaped\\\\"""
39+
40+
def a12():
41+
"""Doesn't need chaperone\\"""
42+
43+
def a12():
44+
"""Doesn't need "chaperone" with contained quotes"""
45+
46+
def a13():
47+
"""Doesn't need chaperone\\\"\"\""""
48+
49+
def a14():
50+
"""Multiline docstring
51+
doesn't need chaperone
52+
"""
53+
54+
def a15():
55+
"""Multiline docstring
56+
doesn't need chaperone\
57+
"""
58+
59+
def a16():
60+
"""Multiline docstring with
61+
odd number of backslashes don't need chaperone\\\
62+
"""
63+
64+
def a17():
65+
"""Multiline docstring with
66+
even number of backslashes don't need chaperone\\\\
67+
"""
68+
69+
def a18():
70+
r"""Raw multiline docstring
71+
doesn't need chaperone\
72+
"""
73+
```
74+
75+
## Output
76+
```python
77+
def a1():
78+
"""Needs chaperone\\" """
79+
80+
81+
def a2():
82+
"""Needs chaperone\\\ """
83+
84+
85+
def a3():
86+
"""Needs chaperone" """
87+
88+
89+
def a4():
90+
"""Needs chaperone\ """
91+
92+
93+
def a5():
94+
"""Needs chaperone\\\\\ """
95+
96+
97+
def a6():
98+
"""Needs chaperone\"" """
99+
100+
101+
def a7():
102+
"""Doesn't need chaperone\" """
103+
104+
105+
def a8():
106+
"""Doesn't need chaperone\'"""
107+
108+
109+
def a9():
110+
"""Doesn't need chaperone\\\" """
111+
112+
113+
def a10():
114+
"""Doesn't need chaperone\\\'"""
115+
116+
117+
def a11():
118+
"""Doesn't need chaperone; all slashes escaped\\\\"""
119+
120+
121+
def a12():
122+
"""Doesn't need chaperone\\"""
123+
124+
125+
def a12():
126+
"""Doesn't need "chaperone" with contained quotes"""
127+
128+
129+
def a13():
130+
"""Doesn't need chaperone\\\"\"\" """
131+
132+
133+
def a14():
134+
"""Multiline docstring
135+
doesn't need chaperone
136+
"""
137+
138+
139+
def a15():
140+
"""Multiline docstring
141+
doesn't need chaperone\
142+
"""
143+
144+
145+
def a16():
146+
"""Multiline docstring with
147+
odd number of backslashes don't need chaperone\\\
148+
"""
149+
150+
151+
def a17():
152+
"""Multiline docstring with
153+
even number of backslashes don't need chaperone\\\\
154+
"""
155+
156+
157+
def a18():
158+
r"""Raw multiline docstring
159+
doesn't need chaperone\
160+
"""
161+
```
162+
163+
164+
## Preview changes
165+
```diff
166+
--- Stable
167+
+++ Preview
168+
@@ -23,7 +23,7 @@
169+
170+
171+
def a7():
172+
- """Doesn't need chaperone\" """
173+
+ """Doesn't need chaperone\""""
174+
175+
176+
def a8():
177+
@@ -31,7 +31,7 @@
178+
179+
180+
def a9():
181+
- """Doesn't need chaperone\\\" """
182+
+ """Doesn't need chaperone\\\""""
183+
184+
185+
def a10():
186+
@@ -51,7 +51,7 @@
187+
188+
189+
def a13():
190+
- """Doesn't need chaperone\\\"\"\" """
191+
+ """Doesn't need chaperone\\\"\"\""""
192+
193+
194+
def a14():
195+
```

0 commit comments

Comments
 (0)