From e75d8ac59c53f3e22eee5c84f4ae3e0dd9df8a96 Mon Sep 17 00:00:00 2001 From: Samuel Cormier-Iijima Date: Wed, 22 Nov 2023 15:23:51 -0500 Subject: [PATCH 1/6] [formatter] Add "preserve" quote-style to mimic Black's skip-string-normalization Fixes #7525 --- .../src/expression/string/docstring.rs | 4 +++- .../ruff_python_formatter/src/expression/string/mod.rs | 10 ++++++---- crates/ruff_python_formatter/src/options.rs | 4 ++++ ruff.schema.json | 3 ++- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/ruff_python_formatter/src/expression/string/docstring.rs b/crates/ruff_python_formatter/src/expression/string/docstring.rs index 41f350bd4deb2..977e5f443b2cb 100644 --- a/crates/ruff_python_formatter/src/expression/string/docstring.rs +++ b/crates/ruff_python_formatter/src/expression/string/docstring.rs @@ -490,7 +490,9 @@ impl<'ast, 'buf, 'fmt, 'src> DocstringLinePrinter<'ast, 'buf, 'fmt, 'src> { // `docstring_code_examples.py` for when this check is relevant. let wrapped = match self.quote_style { QuoteStyle::Single => std::format!("'''{}'''", printed.as_code()), - QuoteStyle::Double => std::format!(r#""""{}""""#, printed.as_code()), + QuoteStyle::Double | QuoteStyle::Preserve => { + std::format!(r#""""{}""""#, printed.as_code()) + } }; let result = ruff_python_parser::parse( &wrapped, diff --git a/crates/ruff_python_formatter/src/expression/string/mod.rs b/crates/ruff_python_formatter/src/expression/string/mod.rs index c7e896daf2c90..639239792a509 100644 --- a/crates/ruff_python_formatter/src/expression/string/mod.rs +++ b/crates/ruff_python_formatter/src/expression/string/mod.rs @@ -385,7 +385,9 @@ impl StringPart { let quotes = match quoting { Quoting::Preserve => self.quotes, Quoting::CanChange => { - if self.prefix.is_raw_string() { + if preferred_style == QuoteStyle::Preserve { + self.quotes + } else if self.prefix.is_raw_string() { choose_quotes_raw(raw_content, self.quotes, preferred_style) } else { choose_quotes(raw_content, self.quotes, preferred_style) @@ -664,7 +666,7 @@ fn choose_quotes(input: &str, quotes: StringQuotes, preferred_style: QuoteStyle) QuoteStyle::Single } } - QuoteStyle::Double => { + QuoteStyle::Double | QuoteStyle::Preserve => { if double_quotes > single_quotes { QuoteStyle::Single } else { @@ -716,8 +718,8 @@ impl Format> for StringQuotes { let quotes = match (self.style, self.triple) { (QuoteStyle::Single, false) => "'", (QuoteStyle::Single, true) => "'''", - (QuoteStyle::Double, false) => "\"", - (QuoteStyle::Double, true) => "\"\"\"", + (QuoteStyle::Double | QuoteStyle::Preserve, false) => "\"", + (QuoteStyle::Double | QuoteStyle::Preserve, true) => "\"\"\"", }; token(quotes).fmt(f) diff --git a/crates/ruff_python_formatter/src/options.rs b/crates/ruff_python_formatter/src/options.rs index a07fabbb9a795..576c66c3aabf7 100644 --- a/crates/ruff_python_formatter/src/options.rs +++ b/crates/ruff_python_formatter/src/options.rs @@ -207,6 +207,7 @@ pub enum QuoteStyle { Single, #[default] Double, + Preserve, } impl QuoteStyle { @@ -214,6 +215,7 @@ impl QuoteStyle { match self { QuoteStyle::Single => '\'', QuoteStyle::Double => '"', + QuoteStyle::Preserve => '"', // not used } } @@ -222,6 +224,7 @@ impl QuoteStyle { match self { QuoteStyle::Single => QuoteStyle::Double, QuoteStyle::Double => QuoteStyle::Single, + QuoteStyle::Preserve => QuoteStyle::Preserve, } } } @@ -245,6 +248,7 @@ impl FromStr for QuoteStyle { match s { "\"" | "double" | "Double" => Ok(Self::Double), "'" | "single" | "Single" => Ok(Self::Single), + "preserve" | "Preserve" => Ok(Self::Preserve), // TODO: replace this error with a diagnostic _ => Err("Value not supported for QuoteStyle"), } diff --git a/ruff.schema.json b/ruff.schema.json index 3bd21232a2551..92381de1a6522 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -2441,7 +2441,8 @@ "type": "string", "enum": [ "single", - "double" + "double", + "preserve" ] }, "RelativeImportsOrder": { From 090b0eed4663e6acce4a89b9077d7ed8b289a2fc Mon Sep 17 00:00:00 2001 From: Samuel Cormier-Iijima Date: Sat, 2 Dec 2023 20:57:21 -0500 Subject: [PATCH 2/6] Add tests for quote-style --- .../fixtures/ruff/quote_style.options.json | 11 + .../test/fixtures/ruff/quote_style.py | 38 ++++ .../snapshots/format@quote_style.py.snap | 210 ++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/quote_style.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/quote_style.py create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/quote_style.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/quote_style.options.json new file mode 100644 index 0000000000000..59431bf1c4874 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/quote_style.options.json @@ -0,0 +1,11 @@ +[ + { + "quote_style": "single" + }, + { + "quote_style": "double" + }, + { + "quote_style": "preserve" + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/quote_style.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/quote_style.py new file mode 100644 index 0000000000000..b08ed0d2ef74f --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/quote_style.py @@ -0,0 +1,38 @@ +'single' +"double" +r'r single' +r"r double" +f'f single' +f"f double" +fr'fr single' +fr"fr double" +rf'rf single' +rf"rf double" +b'b single' +b"b double" +rb'rb single' +rb"rb double" +br'br single' +br"br double" + +'''single triple''' +"""double triple""" +r'''r single triple''' +r"""r double triple""" +f'''f single triple''' +f"""f double triple""" +fr'''fr single triple''' +fr"""fr double triple""" +rf'''rf single triple''' +rf"""rf double triple""" +b'''b single triple''' +b"""b double triple""" +rb'''rb single triple''' +rb"""rb double triple""" +br'''br single triple''' +br"""br double triple""" + +'single1' 'single2' +'single1' "double2" +"double1" 'single2' +"double1" "double2" diff --git a/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap new file mode 100644 index 0000000000000..9db281d6dfd26 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap @@ -0,0 +1,210 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/quote_style.py +--- +## Input +```python +'single' +"double" +r'r single' +r"r double" +f'f single' +f"f double" +fr'fr single' +fr"fr double" +rf'rf single' +rf"rf double" +b'b single' +b"b double" +rb'rb single' +rb"rb double" +br'br single' +br"br double" + +'''single triple''' +"""double triple""" +r'''r single triple''' +r"""r double triple""" +f'''f single triple''' +f"""f double triple""" +fr'''fr single triple''' +fr"""fr double triple""" +rf'''rf single triple''' +rf"""rf double triple""" +b'''b single triple''' +b"""b double triple""" +rb'''rb single triple''' +rb"""rb double triple""" +br'''br single triple''' +br"""br double triple""" + +'single1' 'single2' +'single1' "double2" +"double1" 'single2' +"double1" "double2" +``` + +## Outputs +### Output 1 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Single +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +preview = Disabled +``` + +```python +'single' +'double' +r'r single' +r'r double' +f'f single' +f'f double' +rf'fr single' +rf'fr double' +rf'rf single' +rf'rf double' +b'b single' +b'b double' +rb'rb single' +rb'rb double' +rb'br single' +rb'br double' + +"""single triple""" +"""double triple""" +r"""r single triple""" +r"""r double triple""" +f"""f single triple""" +f"""f double triple""" +rf"""fr single triple""" +rf"""fr double triple""" +rf"""rf single triple""" +rf"""rf double triple""" +b"""b single triple""" +b"""b double triple""" +rb"""rb single triple""" +rb"""rb double triple""" +rb"""br single triple""" +rb"""br double triple""" + +'single1' 'single2' +'single1' 'double2' +'double1' 'single2' +'double1' 'double2' +``` + + +### Output 2 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Double +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +preview = Disabled +``` + +```python +"single" +"double" +r"r single" +r"r double" +f"f single" +f"f double" +rf"fr single" +rf"fr double" +rf"rf single" +rf"rf double" +b"b single" +b"b double" +rb"rb single" +rb"rb double" +rb"br single" +rb"br double" + +"""single triple""" +"""double triple""" +r"""r single triple""" +r"""r double triple""" +f"""f single triple""" +f"""f double triple""" +rf"""fr single triple""" +rf"""fr double triple""" +rf"""rf single triple""" +rf"""rf double triple""" +b"""b single triple""" +b"""b double triple""" +rb"""rb single triple""" +rb"""rb double triple""" +rb"""br single triple""" +rb"""br double triple""" + +"single1" "single2" +"single1" "double2" +"double1" "single2" +"double1" "double2" +``` + + +### Output 3 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Preserve +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +preview = Disabled +``` + +```python +'single' +"double" +r'r single' +r"r double" +f'f single' +f"f double" +rf'fr single' +rf"fr double" +rf'rf single' +rf"rf double" +b'b single' +b"b double" +rb'rb single' +rb"rb double" +rb'br single' +rb"br double" + +"""single triple""" +"""double triple""" +r"""r single triple""" +r"""r double triple""" +f"""f single triple""" +f"""f double triple""" +rf"""fr single triple""" +rf"""fr double triple""" +rf"""rf single triple""" +rf"""rf double triple""" +b"""b single triple""" +b"""b double triple""" +rb"""rb single triple""" +rb"""rb double triple""" +rb"""br single triple""" +rb"""br double triple""" + +'single1' 'single2' +'single1' "double2" +"double1" 'single2' +"double1" "double2" +``` + + + From 9c7949b3e5398cb49baf33432221e190317f2231 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Tue, 5 Dec 2023 12:53:06 +0800 Subject: [PATCH 3/6] Introduce `QuoteChar` for cases where `Preserve` is not a valid variant Signed-off-by: Micha Reiser --- crates/ruff_python_formatter/src/context.rs | 17 +-- .../src/expression/string/docstring.rs | 24 ++-- .../src/expression/string/mod.rs | 136 +++++++++++++----- crates/ruff_python_formatter/src/options.rs | 31 ---- 4 files changed, 120 insertions(+), 88 deletions(-) diff --git a/crates/ruff_python_formatter/src/context.rs b/crates/ruff_python_formatter/src/context.rs index 2e6cdc0d2ed51..ac5dea3710017 100644 --- a/crates/ruff_python_formatter/src/context.rs +++ b/crates/ruff_python_formatter/src/context.rs @@ -1,5 +1,6 @@ use crate::comments::Comments; -use crate::{PyFormatOptions, QuoteStyle}; +use crate::expression::string::QuoteChar; +use crate::PyFormatOptions; use ruff_formatter::{Buffer, FormatContext, GroupId, SourceCode}; use ruff_source_file::Locator; use std::fmt::{Debug, Formatter}; @@ -12,14 +13,14 @@ pub struct PyFormatContext<'a> { comments: Comments<'a>, node_level: NodeLevel, /// Set to a non-None value when the formatter is running on a code - /// snippet within a docstring. The value should be the quote style of the + /// snippet within a docstring. The value should be the quote character of the /// docstring containing the code snippet. /// /// Various parts of the formatter may inspect this state to change how it /// works. For example, multi-line strings will always be written with a /// quote style that is inverted from the one here in order to ensure that /// the formatted Python code will be valid. - docstring: Option, + docstring: Option, } impl<'a> PyFormatContext<'a> { @@ -57,20 +58,20 @@ impl<'a> PyFormatContext<'a> { /// Returns a non-None value only if the formatter is running on a code /// snippet within a docstring. /// - /// The quote style returned corresponds to the quoting used for the + /// The quote character returned corresponds to the quoting used for the /// docstring containing the code snippet currently being formatted. - pub(crate) fn docstring(&self) -> Option { + pub(crate) fn docstring(&self) -> Option { self.docstring } /// Return a new context suitable for formatting code snippets within a /// docstring. /// - /// The quote style given should correspond to the style of quoting used + /// The quote character given should correspond to the quote character used /// for the docstring containing the code snippets. - pub(crate) fn in_docstring(self, style: QuoteStyle) -> PyFormatContext<'a> { + pub(crate) fn in_docstring(self, quote: QuoteChar) -> PyFormatContext<'a> { PyFormatContext { - docstring: Some(style), + docstring: Some(quote), ..self } } diff --git a/crates/ruff_python_formatter/src/expression/string/docstring.rs b/crates/ruff_python_formatter/src/expression/string/docstring.rs index 977e5f443b2cb..dcbbbbc18e6e5 100644 --- a/crates/ruff_python_formatter/src/expression/string/docstring.rs +++ b/crates/ruff_python_formatter/src/expression/string/docstring.rs @@ -13,9 +13,9 @@ use { ruff_text_size::{Ranged, TextLen, TextRange, TextSize}, }; -use crate::{prelude::*, FormatModuleError, QuoteStyle}; +use crate::{prelude::*, FormatModuleError}; -use super::NormalizedString; +use super::{NormalizedString, QuoteChar}; /// Format a docstring by trimming whitespace and adjusting the indentation. /// @@ -139,7 +139,7 @@ pub(super) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form // Edge case: The first line is `""" "content`, so we need to insert chaperone space that keep // inner quotes and closing quotes from getting to close to avoid `""""content` - if trim_both.starts_with(normalized.quotes.style.as_char()) { + if trim_both.starts_with(normalized.quotes.quote_char.as_char()) { space().fmt(f)?; } @@ -192,7 +192,7 @@ pub(super) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form offset, stripped_indentation_length, already_normalized, - quote_style: normalized.quotes.style, + quote_char: normalized.quotes.quote_char, code_example: CodeExample::default(), } .add_iter(lines)?; @@ -250,8 +250,8 @@ struct DocstringLinePrinter<'ast, 'buf, 'fmt, 'src> { /// is, the formatter can take a fast path. already_normalized: bool, - /// The quote style used by the docstring being printed. - quote_style: QuoteStyle, + /// The quote character used by the docstring being printed. + quote_char: QuoteChar, /// The current code example detected in the docstring. code_example: CodeExample<'src>, @@ -466,7 +466,7 @@ impl<'ast, 'buf, 'fmt, 'src> DocstringLinePrinter<'ast, 'buf, 'fmt, 'src> { // instead of later, and as a result, get more consistent // results. .with_indent_style(IndentStyle::Space); - let printed = match docstring_format_source(options, self.quote_style, &codeblob) { + let printed = match docstring_format_source(options, self.quote_char, &codeblob) { Ok(printed) => printed, Err(FormatModuleError::FormatError(err)) => return Err(err), Err( @@ -488,9 +488,9 @@ impl<'ast, 'buf, 'fmt, 'src> DocstringLinePrinter<'ast, 'buf, 'fmt, 'src> { // a docstring. As we fix corner cases over time, we can perhaps // remove this check. See the `doctest_invalid_skipped` tests in // `docstring_code_examples.py` for when this check is relevant. - let wrapped = match self.quote_style { - QuoteStyle::Single => std::format!("'''{}'''", printed.as_code()), - QuoteStyle::Double | QuoteStyle::Preserve => { + let wrapped = match self.quote_char { + QuoteChar::Single => std::format!("'''{}'''", printed.as_code()), + QuoteChar::Double => { std::format!(r#""""{}""""#, printed.as_code()) } }; @@ -1233,7 +1233,7 @@ enum CodeExampleAddAction<'src> { /// inside of a docstring. fn docstring_format_source( options: crate::PyFormatOptions, - docstring_quote_style: QuoteStyle, + docstring_quote_style: QuoteChar, source: &str, ) -> Result { use ruff_python_parser::AsMode; @@ -1260,7 +1260,7 @@ fn docstring_format_source( /// that avoids `content""""` and `content\"""`. This does only applies to un-escaped backslashes, /// so `content\\ """` doesn't need a space while `content\\\ """` does. fn needs_chaperone_space(normalized: &NormalizedString, trim_end: &str) -> bool { - trim_end.ends_with(normalized.quotes.style.as_char()) + trim_end.ends_with(normalized.quotes.quote_char.as_char()) || trim_end.chars().rev().take_while(|c| *c == '\\').count() % 2 == 1 } diff --git a/crates/ruff_python_formatter/src/expression/string/mod.rs b/crates/ruff_python_formatter/src/expression/string/mod.rs index 639239792a509..6469a1ef5d212 100644 --- a/crates/ruff_python_formatter/src/expression/string/mod.rs +++ b/crates/ruff_python_formatter/src/expression/string/mod.rs @@ -322,7 +322,7 @@ impl StringPart { quoting: Quoting, locator: &'a Locator, configured_style: QuoteStyle, - parent_docstring_quote_style: Option, + parent_docstring_quote_char: Option, ) -> NormalizedString<'a> { // Per PEP 8, always prefer double quotes for triple-quoted strings. let preferred_style = if self.quotes.triple { @@ -371,8 +371,8 @@ impl StringPart { // Overall this is a bit of a corner case and just inverting the // style from what the parent ultimately decided upon works, even // if it doesn't have perfect alignment with PEP8. - if let Some(style) = parent_docstring_quote_style { - style.invert() + if let Some(quote) = parent_docstring_quote_char { + QuoteStyle::from(quote.invert()) } else { QuoteStyle::Double } @@ -385,12 +385,14 @@ impl StringPart { let quotes = match quoting { Quoting::Preserve => self.quotes, Quoting::CanChange => { - if preferred_style == QuoteStyle::Preserve { - self.quotes - } else if self.prefix.is_raw_string() { - choose_quotes_raw(raw_content, self.quotes, preferred_style) + if let Some(preferred_quote) = QuoteChar::from_style(preferred_style) { + if self.prefix.is_raw_string() { + choose_quotes_raw(raw_content, self.quotes, preferred_quote) + } else { + choose_quotes(raw_content, self.quotes, preferred_quote) + } } else { - choose_quotes(raw_content, self.quotes, preferred_style) + self.quotes } } }; @@ -525,9 +527,9 @@ impl Format> for StringPrefix { fn choose_quotes_raw( input: &str, quotes: StringQuotes, - preferred_style: QuoteStyle, + preferred_quote: QuoteChar, ) -> StringQuotes { - let preferred_quote_char = preferred_style.as_char(); + let preferred_quote_char = preferred_quote.as_char(); let mut chars = input.chars().peekable(); let contains_unescaped_configured_quotes = loop { match chars.next() { @@ -565,10 +567,10 @@ fn choose_quotes_raw( StringQuotes { triple: quotes.triple, - style: if contains_unescaped_configured_quotes { - quotes.style + quote_char: if contains_unescaped_configured_quotes { + quotes.quote_char } else { - preferred_style + preferred_quote }, } } @@ -581,14 +583,14 @@ fn choose_quotes_raw( /// For triple quoted strings, the preferred quote style is always used, unless the string contains /// a triplet of the quote character (e.g., if double quotes are preferred, double quotes will be /// used unless the string contains `"""`). -fn choose_quotes(input: &str, quotes: StringQuotes, preferred_style: QuoteStyle) -> StringQuotes { - let style = if quotes.triple { +fn choose_quotes(input: &str, quotes: StringQuotes, preferred_quote: QuoteChar) -> StringQuotes { + let quote = if quotes.triple { // True if the string contains a triple quote sequence of the configured quote style. let mut uses_triple_quotes = false; let mut chars = input.chars().peekable(); while let Some(c) = chars.next() { - let preferred_quote_char = preferred_style.as_char(); + let preferred_quote_char = preferred_quote.as_char(); match c { '\\' => { if matches!(chars.peek(), Some('"' | '\\')) { @@ -636,9 +638,9 @@ fn choose_quotes(input: &str, quotes: StringQuotes, preferred_style: QuoteStyle) if uses_triple_quotes { // String contains a triple quote sequence of the configured quote style. // Keep the existing quote style. - quotes.style + quotes.quote_char } else { - preferred_style + preferred_quote } } else { let mut single_quotes = 0u32; @@ -658,19 +660,19 @@ fn choose_quotes(input: &str, quotes: StringQuotes, preferred_style: QuoteStyle) } } - match preferred_style { - QuoteStyle::Single => { + match preferred_quote { + QuoteChar::Single => { if single_quotes > double_quotes { - QuoteStyle::Double + QuoteChar::Double } else { - QuoteStyle::Single + QuoteChar::Single } } - QuoteStyle::Double | QuoteStyle::Preserve => { + QuoteChar::Double => { if double_quotes > single_quotes { - QuoteStyle::Single + QuoteChar::Single } else { - QuoteStyle::Double + QuoteChar::Double } } } @@ -678,14 +680,14 @@ fn choose_quotes(input: &str, quotes: StringQuotes, preferred_style: QuoteStyle) StringQuotes { triple: quotes.triple, - style, + quote_char: quote, } } #[derive(Copy, Clone, Debug)] pub(super) struct StringQuotes { triple: bool, - style: QuoteStyle, + quote_char: QuoteChar, } impl StringQuotes { @@ -693,11 +695,14 @@ impl StringQuotes { let mut chars = input.chars(); let quote_char = chars.next()?; - let style = QuoteStyle::try_from(quote_char).ok()?; + let quote = QuoteChar::try_from(quote_char).ok()?; let triple = chars.next() == Some(quote_char) && chars.next() == Some(quote_char); - Some(Self { triple, style }) + Some(Self { + triple, + quote_char: quote, + }) } pub(super) const fn is_triple(self) -> bool { @@ -715,17 +720,74 @@ impl StringQuotes { impl Format> for StringQuotes { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { - let quotes = match (self.style, self.triple) { - (QuoteStyle::Single, false) => "'", - (QuoteStyle::Single, true) => "'''", - (QuoteStyle::Double | QuoteStyle::Preserve, false) => "\"", - (QuoteStyle::Double | QuoteStyle::Preserve, true) => "\"\"\"", + let quotes = match (self.quote_char, self.triple) { + (QuoteChar::Single, false) => "'", + (QuoteChar::Single, true) => "'''", + (QuoteChar::Double, false) => "\"", + (QuoteChar::Double, true) => "\"\"\"", }; token(quotes).fmt(f) } } +/// The quotation character used to quote a string, byte, or fstring literal. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum QuoteChar { + /// A single quote: `'` + Single, + + /// A double quote: '"' + Double, +} + +impl QuoteChar { + pub const fn as_char(self) -> char { + match self { + QuoteChar::Single => '\'', + QuoteChar::Double => '"', + } + } + + #[must_use] + pub const fn invert(self) -> QuoteChar { + match self { + QuoteChar::Single => QuoteChar::Double, + QuoteChar::Double => QuoteChar::Single, + } + } + + #[must_use] + pub const fn from_style(style: QuoteStyle) -> Option { + match style { + QuoteStyle::Single => Some(QuoteChar::Single), + QuoteStyle::Double => Some(QuoteChar::Double), + QuoteStyle::Preserve => None, + } + } +} + +impl From for QuoteStyle { + fn from(value: QuoteChar) -> Self { + match value { + QuoteChar::Single => QuoteStyle::Single, + QuoteChar::Double => QuoteStyle::Double, + } + } +} + +impl TryFrom for QuoteChar { + type Error = (); + + fn try_from(value: char) -> Result { + match value { + '\'' => Ok(QuoteChar::Single), + '"' => Ok(QuoteChar::Double), + _ => Err(()), + } + } +} + /// Adds the necessary quote escapes and removes unnecessary escape sequences when quoting `input` /// with the provided [`StringQuotes`] style. /// @@ -738,9 +800,9 @@ fn normalize_string(input: &str, quotes: StringQuotes, prefix: StringPrefix) -> // If `last_index` is `0` at the end, then the input is already normalized and can be returned as is. let mut last_index = 0; - let style = quotes.style; - let preferred_quote = style.as_char(); - let opposite_quote = style.invert().as_char(); + let quote = quotes.quote_char; + let preferred_quote = quote.as_char(); + let opposite_quote = quote.invert().as_char(); let mut chars = input.char_indices().peekable(); diff --git a/crates/ruff_python_formatter/src/options.rs b/crates/ruff_python_formatter/src/options.rs index 576c66c3aabf7..8ff979959cfe5 100644 --- a/crates/ruff_python_formatter/src/options.rs +++ b/crates/ruff_python_formatter/src/options.rs @@ -210,37 +210,6 @@ pub enum QuoteStyle { Preserve, } -impl QuoteStyle { - pub const fn as_char(self) -> char { - match self { - QuoteStyle::Single => '\'', - QuoteStyle::Double => '"', - QuoteStyle::Preserve => '"', // not used - } - } - - #[must_use] - pub const fn invert(self) -> QuoteStyle { - match self { - QuoteStyle::Single => QuoteStyle::Double, - QuoteStyle::Double => QuoteStyle::Single, - QuoteStyle::Preserve => QuoteStyle::Preserve, - } - } -} - -impl TryFrom for QuoteStyle { - type Error = (); - - fn try_from(value: char) -> std::result::Result { - match value { - '\'' => Ok(QuoteStyle::Single), - '"' => Ok(QuoteStyle::Double), - _ => Err(()), - } - } -} - impl FromStr for QuoteStyle { type Err = &'static str; From 36a17e9eabda39cf44e29859e07af34521743cf0 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Tue, 5 Dec 2023 12:59:59 +0800 Subject: [PATCH 4/6] Add docstring test cases Signed-off-by: Micha Reiser --- .../test/fixtures/ruff/quote_style.py | 12 ++++ .../snapshots/format@quote_style.py.snap | 60 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/quote_style.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/quote_style.py index b08ed0d2ef74f..8f0d159bebd4a 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/quote_style.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/quote_style.py @@ -36,3 +36,15 @@ 'single1' "double2" "double1" 'single2' "double1" "double2" + +def docstring_single_triple(): + '''single triple''' + +def docstring_double_triple(): + """double triple""" + +def docstring_double(): + "double triple" + +def docstring_single(): + 'single' diff --git a/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap index 9db281d6dfd26..b78c7666e9636 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap @@ -42,6 +42,18 @@ br"""br double triple""" 'single1' "double2" "double1" 'single2' "double1" "double2" + +def docstring_single_triple(): + '''single triple''' + +def docstring_double_triple(): + """double triple""" + +def docstring_double(): + "double triple" + +def docstring_single(): + 'single' ``` ## Outputs @@ -96,6 +108,22 @@ rb"""br double triple""" 'single1' 'double2' 'double1' 'single2' 'double1' 'double2' + + +def docstring_single_triple(): + """single triple""" + + +def docstring_double_triple(): + """double triple""" + + +def docstring_double(): + "double triple" + + +def docstring_single(): + "single" ``` @@ -150,6 +178,22 @@ rb"""br double triple""" "single1" "double2" "double1" "single2" "double1" "double2" + + +def docstring_single_triple(): + """single triple""" + + +def docstring_double_triple(): + """double triple""" + + +def docstring_double(): + "double triple" + + +def docstring_single(): + "single" ``` @@ -204,6 +248,22 @@ rb"""br double triple""" 'single1' "double2" "double1" 'single2' "double1" "double2" + + +def docstring_single_triple(): + """single triple""" + + +def docstring_double_triple(): + """double triple""" + + +def docstring_double(): + "double triple" + + +def docstring_single(): + "single" ``` From 70afb8a66114b849ec4b8d962b5a53f2f60ee284 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Thu, 7 Dec 2023 13:29:50 +0800 Subject: [PATCH 5/6] Update Documentation --- crates/ruff_workspace/src/options.rs | 15 ++++++++++----- ruff.schema.json | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index 442b8d7d2e16d..065e8b77af86f 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -2812,13 +2812,18 @@ pub struct FormatOptions { )] pub indent_style: Option, - /// Whether to prefer single `'` or double `"` quotes for strings. Defaults to double quotes. + /// Configures the preferred quote character for strings. Valid options are: + /// + /// * `double` (default): Use double quotes `"` + /// * `single`: Use single quotes `'` + /// * `preserve` (preview only): Keeps the existing quote character. We don't recommend using this option except for projects + /// that already use a mixture of single and double quotes and can't migrate to using double or single quotes. /// /// In compliance with [PEP 8](https://peps.python.org/pep-0008/) and [PEP 257](https://peps.python.org/pep-0257/), /// Ruff prefers double quotes for multiline strings and docstrings, regardless of the /// configured quote style. /// - /// Ruff may also deviate from this option if using the configured quotes would require + /// Ruff may also deviate from using the configured quotes if doing so requires /// escaping quote characters within the string. For example, given: /// /// ```python @@ -2827,11 +2832,11 @@ pub struct FormatOptions { /// ``` /// /// Ruff will change `a` to use single quotes when using `quote-style = "single"`. However, - /// `b` will be unchanged, as converting to single quotes would require the inner `'` to be - /// escaped, which leads to less readable code: `'It\'s monday morning'`. + /// `b` remains unchanged, as converting to single quotes requires escaping the inner `'`, + /// which leads to less readable code: `'It\'s monday morning'`. This does not apply when using `preserve`. #[option( default = r#"double"#, - value_type = r#""double" | "single""#, + value_type = r#""double" | "single" | "preserve""#, example = r#" # Prefer single quotes over double quotes. quote-style = "single" diff --git a/ruff.schema.json b/ruff.schema.json index 92381de1a6522..90228a59020d0 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -1281,7 +1281,7 @@ ] }, "quote-style": { - "description": "Whether to prefer single `'` or double `\"` quotes for strings. Defaults to double quotes.\n\nIn compliance with [PEP 8](https://peps.python.org/pep-0008/) and [PEP 257](https://peps.python.org/pep-0257/), Ruff prefers double quotes for multiline strings and docstrings, regardless of the configured quote style.\n\nRuff may also deviate from this option if using the configured quotes would require escaping quote characters within the string. For example, given:\n\n```python a = \"a string without any quotes\" b = \"It's monday morning\" ```\n\nRuff will change `a` to use single quotes when using `quote-style = \"single\"`. However, `b` will be unchanged, as converting to single quotes would require the inner `'` to be escaped, which leads to less readable code: `'It\\'s monday morning'`.", + "description": "Configures the preferred quote character for strings. Valid options are:\n\n* `double` (default): Use double quotes `\"` * `single`: Use single quotes `'` * `preserve` (preview only): Keeps the existing quote character. We don't recommend using this option except for projects that already use a mixture of single and double quotes and can't migrate to using double or single quotes.\n\nIn compliance with [PEP 8](https://peps.python.org/pep-0008/) and [PEP 257](https://peps.python.org/pep-0257/), Ruff prefers double quotes for multiline strings and docstrings, regardless of the configured quote style.\n\nRuff may also deviate from using the configured quotes if doing so requires escaping quote characters within the string. For example, given:\n\n```python a = \"a string without any quotes\" b = \"It's monday morning\" ```\n\nRuff will change `a` to use single quotes when using `quote-style = \"single\"`. However, `b` remains unchanged, as converting to single quotes requires escaping the inner `'`, which leads to less readable code: `'It\\'s monday morning'`. This does not apply when using `preserve`.", "anyOf": [ { "$ref": "#/definitions/QuoteStyle" From abd27792c34fb156571073b559ba810087eb26fb Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Thu, 7 Dec 2023 13:46:04 +0800 Subject: [PATCH 6/6] Gate behind preview flag --- crates/ruff_workspace/src/configuration.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index 4c38249b0e5dc..6b3e2abbc45a1 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -158,12 +158,21 @@ impl Configuration { let format = self.format; let format_defaults = FormatterSettings::default(); + let quote_style = format.quote_style.unwrap_or(format_defaults.quote_style); + let format_preview = match format.preview.unwrap_or(global_preview) { + PreviewMode::Disabled => ruff_python_formatter::PreviewMode::Disabled, + PreviewMode::Enabled => ruff_python_formatter::PreviewMode::Enabled, + }; + + if quote_style == QuoteStyle::Preserve && !format_preview.is_enabled() { + return Err(anyhow!( + "'quote-style = preserve' is a preview only feature. Run with '--preview' to enable it." + )); + } + let formatter = FormatterSettings { exclude: FilePatternSet::try_from_iter(format.exclude.unwrap_or_default())?, - preview: match format.preview.unwrap_or(global_preview) { - PreviewMode::Disabled => ruff_python_formatter::PreviewMode::Disabled, - PreviewMode::Enabled => ruff_python_formatter::PreviewMode::Enabled, - }, + preview: format_preview, line_width: self .line_length .map_or(format_defaults.line_width, |length| { @@ -176,7 +185,7 @@ impl Configuration { .map_or(format_defaults.indent_width, |tab_size| { ruff_formatter::IndentWidth::from(NonZeroU8::from(tab_size)) }), - quote_style: format.quote_style.unwrap_or(format_defaults.quote_style), + quote_style, magic_trailing_comma: format .magic_trailing_comma .unwrap_or(format_defaults.magic_trailing_comma),