Skip to content

Commit 0274de1

Browse files
authored
Preserve backslash in raw string literal (#6152)
1 parent a540933 commit 0274de1

File tree

2 files changed

+71
-11
lines changed

2 files changed

+71
-11
lines changed

crates/ruff_python_formatter/src/expression/string.rs

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,11 +203,17 @@ impl Format<PyFormatContext<'_>> for FormatStringPart {
203203
let raw_content_range = relative_raw_content_range + self.part_range.start();
204204

205205
let raw_content = &string_content[relative_raw_content_range];
206-
let preferred_quotes = preferred_quotes(raw_content, quotes, f.options().quote_style());
206+
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())
211+
};
207212

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

210-
let (normalized, contains_newlines) = normalize_string(raw_content, preferred_quotes);
215+
let (normalized, contains_newlines) =
216+
normalize_string(raw_content, preferred_quotes, is_raw_string);
211217

212218
match normalized {
213219
Cow::Borrowed(_) => {
@@ -223,7 +229,7 @@ impl Format<PyFormatContext<'_>> for FormatStringPart {
223229
}
224230

225231
bitflags! {
226-
#[derive(Copy, Clone, Debug)]
232+
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
227233
pub(super) struct StringPrefix: u8 {
228234
const UNICODE = 0b0000_0001;
229235
/// `r"test"`
@@ -264,6 +270,10 @@ impl StringPrefix {
264270
pub(super) const fn text_len(self) -> TextSize {
265271
TextSize::new(self.bits().count_ones())
266272
}
273+
274+
pub(super) const fn is_raw_string(self) -> bool {
275+
matches!(self, StringPrefix::RAW | StringPrefix::RAW_UPPER)
276+
}
267277
}
268278

269279
impl Format<PyFormatContext<'_>> for StringPrefix {
@@ -290,6 +300,54 @@ impl Format<PyFormatContext<'_>> for StringPrefix {
290300
}
291301
}
292302

303+
/// Detects the preferred quotes for raw string `input`.
304+
/// The configured quote style is preferred unless `input` contains unescaped quotes of the
305+
/// configured style. For example, `r"foo"` is preferred over `r'foo'` if the configured
306+
/// quote style is double quotes.
307+
fn preferred_quotes_raw(
308+
input: &str,
309+
quotes: StringQuotes,
310+
configured_style: QuoteStyle,
311+
) -> StringQuotes {
312+
let configured_quote_char = configured_style.as_char();
313+
let mut chars = input.chars().peekable();
314+
let contains_unescaped_configured_quotes = loop {
315+
match chars.next() {
316+
Some('\\') => {
317+
// Ignore escaped characters
318+
chars.next();
319+
}
320+
// `"` or `'`
321+
Some(c) if c == configured_quote_char => {
322+
if !quotes.triple {
323+
break true;
324+
}
325+
326+
if chars.peek() == Some(&configured_quote_char) {
327+
// `""` or `''`
328+
chars.next();
329+
330+
if chars.peek() == Some(&configured_quote_char) {
331+
// `"""` or `'''`
332+
break true;
333+
}
334+
}
335+
}
336+
Some(_) => continue,
337+
None => break false,
338+
}
339+
};
340+
341+
StringQuotes {
342+
triple: quotes.triple,
343+
style: if contains_unescaped_configured_quotes {
344+
quotes.style
345+
} else {
346+
configured_style
347+
},
348+
}
349+
}
350+
293351
/// Detects the preferred quotes for `input`.
294352
/// * single quoted strings: The preferred quote style is the one that requires less escape sequences.
295353
/// * triple quoted strings: Use double quotes except the string contains a sequence of `"""`.
@@ -434,7 +492,11 @@ impl Format<PyFormatContext<'_>> for StringQuotes {
434492
/// with the provided `style`.
435493
///
436494
/// Returns the normalized string and whether it contains new lines.
437-
fn normalize_string(input: &str, quotes: StringQuotes) -> (Cow<str>, ContainsNewlines) {
495+
fn normalize_string(
496+
input: &str,
497+
quotes: StringQuotes,
498+
is_raw: bool,
499+
) -> (Cow<str>, ContainsNewlines) {
438500
// The normalized string if `input` is not yet normalized.
439501
// `output` must remain empty if `input` is already normalized.
440502
let mut output = String::new();
@@ -467,7 +529,7 @@ fn normalize_string(input: &str, quotes: StringQuotes) -> (Cow<str>, ContainsNew
467529
newlines = ContainsNewlines::Yes;
468530
} else if c == '\n' {
469531
newlines = ContainsNewlines::Yes;
470-
} else if !quotes.triple {
532+
} else if !quotes.triple && !is_raw {
471533
if c == '\\' {
472534
if let Some(next) = input.as_bytes().get(index + 1).copied().map(char::from) {
473535
#[allow(clippy::if_same_then_else)]

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,11 @@ f"\"{a}\"{'hello' * b}\"{c}\""
8282
+f"NOT_YET_IMPLEMENTED_ExprJoinedStr"
8383
+f"NOT_YET_IMPLEMENTED_ExprJoinedStr"
8484
r"raw string ftw"
85-
-r"Date d\'expiration:(.*)"
86-
+r"Date d'expiration:(.*)"
85+
r"Date d\'expiration:(.*)"
8786
r'Tricky "quote'
88-
-r"Not-so-tricky \"quote"
87+
r"Not-so-tricky \"quote"
8988
-rf"{yay}"
9089
-"\nThe \"quick\"\nbrown fox\njumps over\nthe 'lazy' dog.\n"
91-
+r'Not-so-tricky "quote'
9290
+f"NOT_YET_IMPLEMENTED_ExprJoinedStr"
9391
+"\n\
9492
+The \"quick\"\n\
@@ -147,9 +145,9 @@ f"NOT_YET_IMPLEMENTED_ExprJoinedStr"
147145
f"NOT_YET_IMPLEMENTED_ExprJoinedStr"
148146
f"NOT_YET_IMPLEMENTED_ExprJoinedStr"
149147
r"raw string ftw"
150-
r"Date d'expiration:(.*)"
148+
r"Date d\'expiration:(.*)"
151149
r'Tricky "quote'
152-
r'Not-so-tricky "quote'
150+
r"Not-so-tricky \"quote"
153151
f"NOT_YET_IMPLEMENTED_ExprJoinedStr"
154152
"\n\
155153
The \"quick\"\n\

0 commit comments

Comments
 (0)