diff --git a/crates/ruff_python_formatter/src/expression/string.rs b/crates/ruff_python_formatter/src/expression/string.rs index 8395122be2235..caa1a0c60290b 100644 --- a/crates/ruff_python_formatter/src/expression/string.rs +++ b/crates/ruff_python_formatter/src/expression/string.rs @@ -203,11 +203,17 @@ impl Format> for FormatStringPart { let raw_content_range = relative_raw_content_range + self.part_range.start(); let raw_content = &string_content[relative_raw_content_range]; - let preferred_quotes = preferred_quotes(raw_content, quotes, f.options().quote_style()); + let is_raw_string = prefix.is_raw_string(); + let preferred_quotes = if is_raw_string { + preferred_quotes_raw(raw_content, quotes, f.options().quote_style()) + } else { + preferred_quotes(raw_content, quotes, f.options().quote_style()) + }; write!(f, [prefix, preferred_quotes])?; - let (normalized, contains_newlines) = normalize_string(raw_content, preferred_quotes); + let (normalized, contains_newlines) = + normalize_string(raw_content, preferred_quotes, is_raw_string); match normalized { Cow::Borrowed(_) => { @@ -223,7 +229,7 @@ impl Format> for FormatStringPart { } bitflags! { - #[derive(Copy, Clone, Debug)] + #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub(super) struct StringPrefix: u8 { const UNICODE = 0b0000_0001; /// `r"test"` @@ -264,6 +270,10 @@ impl StringPrefix { pub(super) const fn text_len(self) -> TextSize { TextSize::new(self.bits().count_ones()) } + + pub(super) const fn is_raw_string(self) -> bool { + matches!(self, StringPrefix::RAW | StringPrefix::RAW_UPPER) + } } impl Format> for StringPrefix { @@ -290,6 +300,54 @@ impl Format> for StringPrefix { } } +/// Detects the preferred quotes for raw string `input`. +/// The configured quote style is preferred unless `input` contains unescaped quotes of the +/// configured style. For example, `r"foo"` is preferred over `r'foo'` if the configured +/// quote style is double quotes. +fn preferred_quotes_raw( + input: &str, + quotes: StringQuotes, + configured_style: QuoteStyle, +) -> StringQuotes { + let configured_quote_char = configured_style.as_char(); + let mut chars = input.chars().peekable(); + let contains_unescaped_configured_quotes = loop { + match chars.next() { + Some('\\') => { + // Ignore escaped characters + chars.next(); + } + // `"` or `'` + Some(c) if c == configured_quote_char => { + if !quotes.triple { + break true; + } + + if chars.peek() == Some(&configured_quote_char) { + // `""` or `''` + chars.next(); + + if chars.peek() == Some(&configured_quote_char) { + // `"""` or `'''` + break true; + } + } + } + Some(_) => continue, + None => break false, + } + }; + + StringQuotes { + triple: quotes.triple, + style: if contains_unescaped_configured_quotes { + quotes.style + } else { + configured_style + }, + } +} + /// Detects the preferred quotes for `input`. /// * single quoted strings: The preferred quote style is the one that requires less escape sequences. /// * triple quoted strings: Use double quotes except the string contains a sequence of `"""`. @@ -434,7 +492,11 @@ impl Format> for StringQuotes { /// with the provided `style`. /// /// Returns the normalized string and whether it contains new lines. -fn normalize_string(input: &str, quotes: StringQuotes) -> (Cow, ContainsNewlines) { +fn normalize_string( + input: &str, + quotes: StringQuotes, + is_raw: bool, +) -> (Cow, ContainsNewlines) { // The normalized string if `input` is not yet normalized. // `output` must remain empty if `input` is already normalized. let mut output = String::new(); @@ -467,7 +529,7 @@ fn normalize_string(input: &str, quotes: StringQuotes) -> (Cow, ContainsNew newlines = ContainsNewlines::Yes; } else if c == '\n' { newlines = ContainsNewlines::Yes; - } else if !quotes.triple { + } else if !quotes.triple && !is_raw { if c == '\\' { if let Some(next) = input.as_bytes().get(index + 1).copied().map(char::from) { #[allow(clippy::if_same_then_else)] diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@miscellaneous__string_quotes.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@miscellaneous__string_quotes.py.snap index f2d2835cccdfd..9473281ef2a44 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@miscellaneous__string_quotes.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@miscellaneous__string_quotes.py.snap @@ -82,13 +82,11 @@ f"\"{a}\"{'hello' * b}\"{c}\"" +f"NOT_YET_IMPLEMENTED_ExprJoinedStr" +f"NOT_YET_IMPLEMENTED_ExprJoinedStr" r"raw string ftw" --r"Date d\'expiration:(.*)" -+r"Date d'expiration:(.*)" + r"Date d\'expiration:(.*)" r'Tricky "quote' --r"Not-so-tricky \"quote" + r"Not-so-tricky \"quote" -rf"{yay}" -"\nThe \"quick\"\nbrown fox\njumps over\nthe 'lazy' dog.\n" -+r'Not-so-tricky "quote' +f"NOT_YET_IMPLEMENTED_ExprJoinedStr" +"\n\ +The \"quick\"\n\ @@ -147,9 +145,9 @@ f"NOT_YET_IMPLEMENTED_ExprJoinedStr" f"NOT_YET_IMPLEMENTED_ExprJoinedStr" f"NOT_YET_IMPLEMENTED_ExprJoinedStr" r"raw string ftw" -r"Date d'expiration:(.*)" +r"Date d\'expiration:(.*)" r'Tricky "quote' -r'Not-so-tricky "quote' +r"Not-so-tricky \"quote" f"NOT_YET_IMPLEMENTED_ExprJoinedStr" "\n\ The \"quick\"\n\