diff --git a/Cargo.lock b/Cargo.lock index 48ab3825b2651..ffaedcc63c1de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2999,6 +2999,7 @@ dependencies = [ "ruff_python_parser", "ruff_source_file", "ruff_text_size", + "test-case", ] [[package]] diff --git a/crates/red_knot_python_semantic/src/types/display.rs b/crates/red_knot_python_semantic/src/types/display.rs index 50c3ae1fff8cb..23b6395ca4e9a 100644 --- a/crates/red_knot_python_semantic/src/types/display.rs +++ b/crates/red_knot_python_semantic/src/types/display.rs @@ -3,7 +3,7 @@ use std::fmt::{self, Display, Formatter, Write}; use ruff_db::display::FormatterJoinExtension; -use ruff_python_ast::str::Quote; +use ruff_python_ast::str::{Quote, TripleQuotes}; use ruff_python_literal::escape::AsciiEscape; use crate::types::class_base::ClassBase; @@ -98,7 +98,7 @@ impl Display for DisplayRepresentation<'_> { let escape = AsciiEscape::with_preferred_quote(bytes.value(self.db).as_ref(), Quote::Double); - escape.bytes_repr().write(f) + escape.bytes_repr(TripleQuotes::No).write(f) } Type::SliceLiteral(slice) => { f.write_str("slice[")?; diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM905.py b/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM905.py index ad7da0b856223..7d767d475385b 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM905.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM905.py @@ -97,3 +97,12 @@ "hello\nworld".splitlines() "hello\nworld".splitlines(keepends=True) "hello\nworld".splitlines(keepends=False) + + +# another positive demonstrating quote preservation +""" +"itemA" +'itemB' +'''itemC''' +"'itemD'" +""".split() diff --git a/crates/ruff_linter/src/rules/flake8_quotes/rules/unnecessary_escaped_quote.rs b/crates/ruff_linter/src/rules/flake8_quotes/rules/unnecessary_escaped_quote.rs index d47fd82f3a607..22f5e8eba8f5a 100644 --- a/crates/ruff_linter/src/rules/flake8_quotes/rules/unnecessary_escaped_quote.rs +++ b/crates/ruff_linter/src/rules/flake8_quotes/rules/unnecessary_escaped_quote.rs @@ -108,7 +108,9 @@ fn check_string_or_bytes( let mut diagnostic = Diagnostic::new(UnnecessaryEscapedQuote, range); diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement( - flags.format_string_contents(&unescape_string(contents, opposite_quote_char)), + flags + .display_contents(&unescape_string(contents, opposite_quote_char)) + .to_string(), range, ))); Some(diagnostic) diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/split_static_string.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/split_static_string.rs index f22e7d8664e6b..f58ed95342fde 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/split_static_string.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/split_static_string.rs @@ -3,8 +3,8 @@ use std::cmp::Ordering; use ruff_diagnostics::{Applicability, Diagnostic, Edit, Fix, FixAvailability, Violation}; use ruff_macros::{derive_message_formats, ViolationMetadata}; use ruff_python_ast::{ - Expr, ExprCall, ExprContext, ExprList, ExprUnaryOp, StringLiteral, StringLiteralFlags, - StringLiteralValue, UnaryOp, + str::TripleQuotes, Expr, ExprCall, ExprContext, ExprList, ExprUnaryOp, StringLiteral, + StringLiteralFlags, StringLiteralValue, UnaryOp, }; use ruff_text_size::{Ranged, TextRange}; @@ -123,7 +123,17 @@ fn construct_replacement(elts: &[&str], flags: StringLiteralFlags) -> Expr { Expr::from(StringLiteral { value: Box::from(*elt), range: TextRange::default(), - flags, + // intentionally omit the triple quote flag, if set, to avoid strange + // replacements like + // + // ```python + // """ + // itemA + // itemB + // itemC + // """.split() # -> ["""itemA""", """itemB""", """itemC"""] + // ``` + flags: flags.with_triple_quotes(TripleQuotes::No), }) }) .collect(), diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM905_SIM905.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM905_SIM905.py.snap index b842a85e23e64..62db77198bc15 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM905_SIM905.py.snap +++ b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM905_SIM905.py.snap @@ -842,4 +842,29 @@ SIM905.py:72:1: SIM905 [*] Consider using a list literal instead of `str.split` 72 |+["a,b,c"] # ["a,b,c"] 73 73 | 74 74 | # negatives -75 75 | +75 75 | + +SIM905.py:103:1: SIM905 [*] Consider using a list literal instead of `str.split` + | +102 | # another positive demonstrating quote preservation +103 | / """ +104 | | "itemA" +105 | | 'itemB' +106 | | '''itemC''' +107 | | "'itemD'" +108 | | """.split() + | |___________^ SIM905 + | + = help: Replace with list literal + +ℹ Safe fix +100 100 | +101 101 | +102 102 | # another positive demonstrating quote preservation +103 |-""" +104 |-"itemA" +105 |-'itemB' +106 |-'''itemC''' +107 |-"'itemD'" +108 |-""".split() + 103 |+['"itemA"', "'itemB'", "'''itemC'''", "\"'itemD'\""] diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/printf_string_formatting.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/printf_string_formatting.rs index 36f764de95efe..3917f8bf6b4cb 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/printf_string_formatting.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/printf_string_formatting.rs @@ -405,7 +405,9 @@ pub(crate) fn printf_string_formatting( // Convert the `%`-format string to a `.format` string. format_strings.push(( string_literal.range(), - flags.format_string_contents(&percent_to_format(&format_string)), + flags + .display_contents(&percent_to_format(&format_string)) + .to_string(), )); } diff --git a/crates/ruff_python_ast/src/nodes.rs b/crates/ruff_python_ast/src/nodes.rs index 618fa5c71c281..f070d2cbdec6f 100644 --- a/crates/ruff_python_ast/src/nodes.rs +++ b/crates/ruff_python_ast/src/nodes.rs @@ -13,10 +13,10 @@ use itertools::Itertools; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; -use crate::name::Name; use crate::{ int, - str::Quote, + name::Name, + str::{Quote, TripleQuotes}, str_prefix::{AnyStringPrefix, ByteStringPrefix, FStringPrefix, StringLiteralPrefix}, ExceptHandler, Expr, FStringElement, LiteralExpressionRef, Pattern, Stmt, TypeParam, }; @@ -981,25 +981,24 @@ pub trait StringFlags: Copy { /// Does the string use single or double quotes in its opener and closer? fn quote_style(self) -> Quote; - /// Is the string triple-quoted, i.e., - /// does it begin and end with three consecutive quote characters? - fn is_triple_quoted(self) -> bool; + fn triple_quotes(self) -> TripleQuotes; fn prefix(self) -> AnyStringPrefix; + /// Is the string triple-quoted, i.e., + /// does it begin and end with three consecutive quote characters? + fn is_triple_quoted(self) -> bool { + self.triple_quotes().is_yes() + } + /// A `str` representation of the quotes used to start and close. /// This does not include any prefixes the string has in its opener. fn quote_str(self) -> &'static str { - if self.is_triple_quoted() { - match self.quote_style() { - Quote::Single => "'''", - Quote::Double => r#"""""#, - } - } else { - match self.quote_style() { - Quote::Single => "'", - Quote::Double => "\"", - } + match (self.triple_quotes(), self.quote_style()) { + (TripleQuotes::Yes, Quote::Single) => "'''", + (TripleQuotes::Yes, Quote::Double) => r#"""""#, + (TripleQuotes::No, Quote::Single) => "'", + (TripleQuotes::No, Quote::Double) => "\"", } } @@ -1028,10 +1027,30 @@ pub trait StringFlags: Copy { self.quote_len() } - fn format_string_contents(self, contents: &str) -> String { - let prefix = self.prefix(); - let quote_str = self.quote_str(); - format!("{prefix}{quote_str}{contents}{quote_str}") + fn display_contents(self, contents: &str) -> DisplayFlags { + DisplayFlags { + prefix: self.prefix(), + quote_str: self.quote_str(), + contents, + } + } +} + +pub struct DisplayFlags<'a> { + prefix: AnyStringPrefix, + quote_str: &'a str, + contents: &'a str, +} + +impl std::fmt::Display for DisplayFlags<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{prefix}{quote}{contents}{quote}", + prefix = self.prefix, + quote = self.quote_str, + contents = self.contents + ) } } @@ -1097,8 +1116,9 @@ impl FStringFlags { } #[must_use] - pub fn with_triple_quotes(mut self) -> Self { - self.0 |= FStringFlagsInner::TRIPLE_QUOTED; + pub fn with_triple_quotes(mut self, triple_quotes: TripleQuotes) -> Self { + self.0 + .set(FStringFlagsInner::TRIPLE_QUOTED, triple_quotes.is_yes()); self } @@ -1132,8 +1152,12 @@ impl StringFlags for FStringFlags { /// Return `true` if the f-string is triple-quoted, i.e., /// it begins and ends with three consecutive quote characters. /// For example: `f"""{bar}"""` - fn is_triple_quoted(self) -> bool { - self.0.contains(FStringFlagsInner::TRIPLE_QUOTED) + fn triple_quotes(self) -> TripleQuotes { + if self.0.contains(FStringFlagsInner::TRIPLE_QUOTED) { + TripleQuotes::Yes + } else { + TripleQuotes::No + } } /// Return the quoting style (single or double quotes) @@ -1477,8 +1501,11 @@ impl StringLiteralFlags { } #[must_use] - pub fn with_triple_quotes(mut self) -> Self { - self.0 |= StringLiteralFlagsInner::TRIPLE_QUOTED; + pub fn with_triple_quotes(mut self, triple_quotes: TripleQuotes) -> Self { + self.0.set( + StringLiteralFlagsInner::TRIPLE_QUOTED, + triple_quotes.is_yes(), + ); self } @@ -1550,8 +1577,12 @@ impl StringFlags for StringLiteralFlags { /// Return `true` if the string is triple-quoted, i.e., /// it begins and ends with three consecutive quote characters. /// For example: `"""bar"""` - fn is_triple_quoted(self) -> bool { - self.0.contains(StringLiteralFlagsInner::TRIPLE_QUOTED) + fn triple_quotes(self) -> TripleQuotes { + if self.0.contains(StringLiteralFlagsInner::TRIPLE_QUOTED) { + TripleQuotes::Yes + } else { + TripleQuotes::No + } } fn prefix(self) -> AnyStringPrefix { @@ -1866,8 +1897,11 @@ impl BytesLiteralFlags { } #[must_use] - pub fn with_triple_quotes(mut self) -> Self { - self.0 |= BytesLiteralFlagsInner::TRIPLE_QUOTED; + pub fn with_triple_quotes(mut self, triple_quotes: TripleQuotes) -> Self { + self.0.set( + BytesLiteralFlagsInner::TRIPLE_QUOTED, + triple_quotes.is_yes(), + ); self } @@ -1910,8 +1944,12 @@ impl StringFlags for BytesLiteralFlags { /// Return `true` if the bytestring is triple-quoted, i.e., /// it begins and ends with three consecutive quote characters. /// For example: `b"""{bar}"""` - fn is_triple_quoted(self) -> bool { - self.0.contains(BytesLiteralFlagsInner::TRIPLE_QUOTED) + fn triple_quotes(self) -> TripleQuotes { + if self.0.contains(BytesLiteralFlagsInner::TRIPLE_QUOTED) { + TripleQuotes::Yes + } else { + TripleQuotes::No + } } /// Return the quoting style (single or double quotes) @@ -2035,7 +2073,7 @@ bitflags! { } } -#[derive(Default, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct AnyStringFlags(AnyStringFlagsInner); impl AnyStringFlags { @@ -2073,13 +2111,11 @@ impl AnyStringFlags { self } - pub fn new(prefix: AnyStringPrefix, quotes: Quote, triple_quoted: bool) -> Self { - let new = Self::default().with_prefix(prefix).with_quote_style(quotes); - if triple_quoted { - new.with_triple_quotes() - } else { - new - } + pub fn new(prefix: AnyStringPrefix, quotes: Quote, triple_quotes: TripleQuotes) -> Self { + Self(AnyStringFlagsInner::empty()) + .with_prefix(prefix) + .with_quote_style(quotes) + .with_triple_quotes(triple_quotes) } /// Does the string have a `u` or `U` prefix? @@ -2114,8 +2150,9 @@ impl AnyStringFlags { } #[must_use] - pub fn with_triple_quotes(mut self) -> Self { - self.0 |= AnyStringFlagsInner::TRIPLE_QUOTED; + pub fn with_triple_quotes(mut self, triple_quotes: TripleQuotes) -> Self { + self.0 + .set(AnyStringFlagsInner::TRIPLE_QUOTED, triple_quotes.is_yes()); self } } @@ -2130,10 +2167,12 @@ impl StringFlags for AnyStringFlags { } } - /// Is the string triple-quoted, i.e., - /// does it begin and end with three consecutive quote characters? - fn is_triple_quoted(self) -> bool { - self.0.contains(AnyStringFlagsInner::TRIPLE_QUOTED) + fn triple_quotes(self) -> TripleQuotes { + if self.0.contains(AnyStringFlagsInner::TRIPLE_QUOTED) { + TripleQuotes::Yes + } else { + TripleQuotes::No + } } fn prefix(self) -> AnyStringPrefix { @@ -2193,14 +2232,10 @@ impl From for StringLiteralFlags { value.prefix() ) }; - let new = StringLiteralFlags::empty() + StringLiteralFlags::empty() .with_quote_style(value.quote_style()) - .with_prefix(prefix); - if value.is_triple_quoted() { - new.with_triple_quotes() - } else { - new - } + .with_prefix(prefix) + .with_triple_quotes(value.triple_quotes()) } } @@ -2209,7 +2244,7 @@ impl From for AnyStringFlags { Self::new( AnyStringPrefix::Regular(value.prefix()), value.quote_style(), - value.is_triple_quoted(), + value.triple_quotes(), ) } } @@ -2222,14 +2257,10 @@ impl From for BytesLiteralFlags { value.prefix() ) }; - let new = BytesLiteralFlags::empty() + BytesLiteralFlags::empty() .with_quote_style(value.quote_style()) - .with_prefix(bytestring_prefix); - if value.is_triple_quoted() { - new.with_triple_quotes() - } else { - new - } + .with_prefix(bytestring_prefix) + .with_triple_quotes(value.triple_quotes()) } } @@ -2238,7 +2269,7 @@ impl From for AnyStringFlags { Self::new( AnyStringPrefix::Bytes(value.prefix()), value.quote_style(), - value.is_triple_quoted(), + value.triple_quotes(), ) } } @@ -2251,14 +2282,10 @@ impl From for FStringFlags { value.prefix() ) }; - let new = FStringFlags::empty() + FStringFlags::empty() .with_quote_style(value.quote_style()) - .with_prefix(fstring_prefix); - if value.is_triple_quoted() { - new.with_triple_quotes() - } else { - new - } + .with_prefix(fstring_prefix) + .with_triple_quotes(value.triple_quotes()) } } @@ -2267,7 +2294,7 @@ impl From for AnyStringFlags { Self::new( AnyStringPrefix::Format(value.prefix()), value.quote_style(), - value.is_triple_quoted(), + value.triple_quotes(), ) } } diff --git a/crates/ruff_python_ast/src/str.rs b/crates/ruff_python_ast/src/str.rs index 33a9bf0d0cada..5a8dd1093e3b5 100644 --- a/crates/ruff_python_ast/src/str.rs +++ b/crates/ruff_python_ast/src/str.rs @@ -68,6 +68,24 @@ impl TryFrom for Quote { } } +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum TripleQuotes { + Yes, + No, +} + +impl TripleQuotes { + #[must_use] + pub const fn is_yes(self) -> bool { + matches!(self, Self::Yes) + } + + #[must_use] + pub const fn is_no(self) -> bool { + matches!(self, Self::No) + } +} + /// Includes all permutations of `r`, `u`, `f`, and `fr` (`ur` is invalid, as is `uf`). This /// includes all possible orders, and all possible casings, for both single and triple quotes. /// diff --git a/crates/ruff_python_codegen/Cargo.toml b/crates/ruff_python_codegen/Cargo.toml index 35dcbc30116d9..57a400ec825cf 100644 --- a/crates/ruff_python_codegen/Cargo.toml +++ b/crates/ruff_python_codegen/Cargo.toml @@ -20,6 +20,8 @@ ruff_python_parser = { workspace = true } ruff_source_file = { workspace = true } ruff_text_size = { workspace = true } +[dev-dependencies] +test-case = { workspace = true } [lints] workspace = true diff --git a/crates/ruff_python_codegen/src/generator.rs b/crates/ruff_python_codegen/src/generator.rs index 587e805219908..081a2dbeace25 100644 --- a/crates/ruff_python_codegen/src/generator.rs +++ b/crates/ruff_python_codegen/src/generator.rs @@ -1,13 +1,13 @@ //! Generate Python source code from an abstract syntax tree (AST). +use std::fmt::Write; use std::ops::Deref; -use ruff_python_ast::str::Quote; use ruff_python_ast::{ - self as ast, Alias, ArgOrKeyword, BoolOp, CmpOp, Comprehension, ConversionFlag, DebugText, - ExceptHandler, Expr, Identifier, MatchCase, Operator, Parameter, Parameters, Pattern, - Singleton, Stmt, StringFlags, Suite, TypeParam, TypeParamParamSpec, TypeParamTypeVar, - TypeParamTypeVarTuple, WithItem, + self as ast, Alias, AnyStringFlags, ArgOrKeyword, BoolOp, BytesLiteralFlags, CmpOp, + Comprehension, ConversionFlag, DebugText, ExceptHandler, Expr, FStringFlags, Identifier, + MatchCase, Operator, Parameter, Parameters, Pattern, Singleton, Stmt, StringFlags, Suite, + TypeParam, TypeParamParamSpec, TypeParamTypeVar, TypeParamTypeVarTuple, WithItem, }; use ruff_python_ast::{ParameterWithDefault, TypeParams}; use ruff_python_literal::escape::{AsciiEscape, Escape, UnicodeEscape}; @@ -146,20 +146,44 @@ impl<'a> Generator<'a> { self.p(s.as_str()); } - fn p_bytes_repr(&mut self, s: &[u8], quote: Quote) { - let escape = AsciiEscape::with_preferred_quote(s, quote); + fn p_bytes_repr(&mut self, s: &[u8], flags: BytesLiteralFlags) { + // raw bytes are interpreted without escapes and should all be ascii (it's a python syntax + // error otherwise), but if this assumption is violated, a `Utf8Error` will be returned from + // `p_raw_bytes`, and we should fall back on the normal escaping behavior instead of + // panicking + if flags.prefix().is_raw() { + if let Ok(s) = std::str::from_utf8(s) { + write!(self.buffer, "{}", flags.display_contents(s)) + .expect("Writing to a String buffer should never fail"); + return; + } + } + let escape = AsciiEscape::with_preferred_quote(s, flags.quote_style()); if let Some(len) = escape.layout().len { self.buffer.reserve(len); } - escape.bytes_repr().write(&mut self.buffer).unwrap(); // write to string doesn't fail + escape + .bytes_repr(flags.triple_quotes()) + .write(&mut self.buffer) + .expect("Writing to a String buffer should never fail"); } - fn p_str_repr(&mut self, s: &str, quote: Quote) { - let escape = UnicodeEscape::with_preferred_quote(s, quote); + fn p_str_repr(&mut self, s: &str, flags: impl Into) { + let flags = flags.into(); + if flags.prefix().is_raw() { + write!(self.buffer, "{}", flags.display_contents(s)) + .expect("Writing to a String buffer should never fail"); + return; + } + self.p(flags.prefix().as_str()); + let escape = UnicodeEscape::with_preferred_quote(s, flags.quote_style()); if let Some(len) = escape.layout().len { self.buffer.reserve(len); } - escape.str_repr().write(&mut self.buffer).unwrap(); // write to string doesn't fail + escape + .str_repr(flags.triple_quotes()) + .write(&mut self.buffer) + .expect("Writing to a String buffer should never fail"); } fn p_if(&mut self, cond: bool, s: &str) { @@ -1093,7 +1117,7 @@ impl<'a> Generator<'a> { let mut first = true; for bytes_literal in value { self.p_delim(&mut first, " "); - self.p_bytes_repr(&bytes_literal.value, bytes_literal.flags.quote_style()); + self.p_bytes_repr(&bytes_literal.value, bytes_literal.flags); } } Expr::NumberLiteral(ast::ExprNumberLiteral { value, .. }) => { @@ -1280,19 +1304,7 @@ impl<'a> Generator<'a> { fn unparse_string_literal(&mut self, string_literal: &ast::StringLiteral) { let ast::StringLiteral { value, flags, .. } = string_literal; - // for raw strings, we don't want to perform the UnicodeEscape in `p_str_repr`, so build the - // replacement here - if flags.prefix().is_raw() { - self.p(flags.prefix().as_str()); - self.p(flags.quote_str()); - self.p(value); - self.p(flags.quote_str()); - } else { - if flags.prefix().is_unicode() { - self.p("u"); - } - self.p_str_repr(value, flags.quote_style()); - } + self.p_str_repr(value, *flags); } fn unparse_string_literal_value(&mut self, value: &ast::StringLiteralValue) { @@ -1312,7 +1324,7 @@ impl<'a> Generator<'a> { self.unparse_string_literal(string_literal); } ast::FStringPart::FString(f_string) => { - self.unparse_f_string(&f_string.elements, f_string.flags.quote_style()); + self.unparse_f_string(&f_string.elements, f_string.flags); } } } @@ -1396,12 +1408,11 @@ impl<'a> Generator<'a> { /// Unparse `values` with [`Generator::unparse_f_string_body`], using `quote` as the preferred /// surrounding quote style. - fn unparse_f_string(&mut self, values: &[ast::FStringElement], quote: Quote) { - self.p("f"); + fn unparse_f_string(&mut self, values: &[ast::FStringElement], flags: FStringFlags) { let mut generator = Generator::new(self.indent, self.line_ending); generator.unparse_f_string_body(values); let body = &generator.buffer; - self.p_str_repr(body, quote); + self.p_str_repr(body, flags); } fn unparse_alias(&mut self, alias: &Alias) { @@ -1724,10 +1735,53 @@ class Foo: assert_round_trip!(r#"f"hello""#); assert_eq!(round_trip(r#"("abc" "def" "ghi")"#), r#""abc" "def" "ghi""#); assert_eq!(round_trip(r#""he\"llo""#), r#"'he"llo'"#); + assert_eq!(round_trip(r#"b"he\"llo""#), r#"b'he"llo'"#); assert_eq!(round_trip(r#"f"abc{'def'}{1}""#), r#"f"abc{'def'}{1}""#); assert_round_trip!(r#"f'abc{"def"}{1}'"#); } + /// test all of the valid string literal prefix and quote combinations from + /// https://docs.python.org/3/reference/lexical_analysis.html#string-and-bytes-literals + /// + /// Note that the numeric ids on the input/output and quote fields prevent name conflicts from + /// the test_matrix but are otherwise unnecessary + #[test_case::test_matrix( + [ + ("r", "r", 0), + ("u", "u", 1), + ("R", "R", 2), + ("U", "u", 3), // case not tracked + ("f", "f", 4), + ("F", "f", 5), // f case not tracked + ("fr", "rf", 6), // r before f + ("Fr", "rf", 7), // f case not tracked, r before f + ("fR", "Rf", 8), // r before f + ("FR", "Rf", 9), // f case not tracked, r before f + ("rf", "rf", 10), + ("rF", "rf", 11), // f case not tracked + ("Rf", "Rf", 12), + ("RF", "Rf", 13), // f case not tracked + // bytestrings + ("b", "b", 14), + ("B", "b", 15), // b case + ("br", "rb", 16), // r before b + ("Br", "rb", 17), // b case, r before b + ("bR", "Rb", 18), // r before b + ("BR", "Rb", 19), // b case, r before b + ("rb", "rb", 20), + ("rB", "rb", 21), // b case + ("Rb", "Rb", 22), + ("RB", "Rb", 23), // b case + ], + [("\"", 0), ("'",1), ("\"\"\"", 2), ("'''", 3)], + ["hello", "{hello} {world}"] + )] + fn prefix_quotes((inp, out, _id): (&str, &str, u8), (quote, _id2): (&str, u8), base: &str) { + let input = format!("{inp}{quote}{base}{quote}"); + let output = format!("{out}{quote}{base}{quote}"); + assert_eq!(round_trip(&input), output); + } + #[test] fn raw() { assert_round_trip!(r#"r"a\.b""#); // https://github.com/astral-sh/ruff/issues/9663 diff --git a/crates/ruff_python_formatter/src/string/implicit.rs b/crates/ruff_python_formatter/src/string/implicit.rs index 88061d0792aff..b152d79ec3783 100644 --- a/crates/ruff_python_formatter/src/string/implicit.rs +++ b/crates/ruff_python_formatter/src/string/implicit.rs @@ -1,6 +1,6 @@ use itertools::Itertools; use ruff_formatter::{format_args, write, FormatContext}; -use ruff_python_ast::str::Quote; +use ruff_python_ast::str::{Quote, TripleQuotes}; use ruff_python_ast::str_prefix::{ AnyStringPrefix, ByteStringPrefix, FStringPrefix, StringLiteralPrefix, }; @@ -230,7 +230,7 @@ impl<'a> FormatImplicitConcatenatedStringFlat<'a> { } }; - Some(AnyStringFlags::new(prefix, quote, false)) + Some(AnyStringFlags::new(prefix, quote, TripleQuotes::No)) } if !string.is_implicit_concatenated() { diff --git a/crates/ruff_python_formatter/src/string/mod.rs b/crates/ruff_python_formatter/src/string/mod.rs index ca4e59fda350c..5e2183be3a864 100644 --- a/crates/ruff_python_formatter/src/string/mod.rs +++ b/crates/ruff_python_formatter/src/string/mod.rs @@ -1,6 +1,6 @@ use memchr::memchr2; pub(crate) use normalize::{normalize_string, NormalizedString, StringNormalizer}; -use ruff_python_ast::str::Quote; +use ruff_python_ast::str::{Quote, TripleQuotes}; use ruff_python_ast::StringLikePart; use ruff_python_ast::{ self as ast, @@ -95,11 +95,11 @@ impl StringLikeExtensions for ast::StringLike<'_> { fn contains_line_break_or_comments( elements: &ast::FStringElements, context: &PyFormatContext, - is_triple_quoted: bool, + triple_quotes: TripleQuotes, ) -> bool { elements.iter().any(|element| match element { ast::FStringElement::Literal(literal) => { - is_triple_quoted + triple_quotes.is_yes() && context.source().contains_line_break(literal.range()) } ast::FStringElement::Expression(expression) => { @@ -119,7 +119,7 @@ impl StringLikeExtensions for ast::StringLike<'_> { contains_line_break_or_comments( &spec.elements, context, - is_triple_quoted, + triple_quotes, ) }) || expression.debug_text.as_ref().is_some_and(|debug_text| { @@ -134,7 +134,7 @@ impl StringLikeExtensions for ast::StringLike<'_> { contains_line_break_or_comments( &f_string.elements, context, - f_string.flags.is_triple_quoted(), + f_string.flags.triple_quotes(), ) } }) diff --git a/crates/ruff_python_formatter/src/string/normalize.rs b/crates/ruff_python_formatter/src/string/normalize.rs index f15fe896418a9..0c3e0cb6f3975 100644 --- a/crates/ruff_python_formatter/src/string/normalize.rs +++ b/crates/ruff_python_formatter/src/string/normalize.rs @@ -5,8 +5,9 @@ use std::iter::FusedIterator; use ruff_formatter::FormatContext; use ruff_python_ast::visitor::source_order::SourceOrderVisitor; use ruff_python_ast::{ - str::Quote, AnyStringFlags, BytesLiteral, FString, FStringElement, FStringElements, - FStringFlags, StringFlags, StringLikePart, StringLiteral, + str::{Quote, TripleQuotes}, + AnyStringFlags, BytesLiteral, FString, FStringElement, FStringElements, FStringFlags, + StringFlags, StringLikePart, StringLiteral, }; use ruff_text_size::{Ranged, TextRange, TextSlice}; @@ -273,7 +274,7 @@ impl QuoteMetadata { pub(crate) fn from_str(text: &str, flags: AnyStringFlags, preferred_quote: Quote) -> Self { let kind = if flags.is_raw_string() { - QuoteMetadataKind::raw(text, preferred_quote, flags.is_triple_quoted()) + QuoteMetadataKind::raw(text, preferred_quote, flags.triple_quotes()) } else if flags.is_triple_quoted() { QuoteMetadataKind::triple_quoted(text, preferred_quote) } else { @@ -528,7 +529,7 @@ impl QuoteMetadataKind { /// Computes if a raw string uses the preferred quote. If it does, then it's not possible /// to change the quote style because it would require escaping which isn't possible in raw strings. - fn raw(text: &str, preferred: Quote, triple_quoted: bool) -> Self { + fn raw(text: &str, preferred: Quote, triple_quotes: TripleQuotes) -> Self { let mut chars = text.chars().peekable(); let preferred_quote_char = preferred.as_char(); @@ -540,7 +541,7 @@ impl QuoteMetadataKind { } // `"` or `'` Some(c) if c == preferred_quote_char => { - if !triple_quoted { + if triple_quotes.is_no() { break true; } @@ -1057,7 +1058,7 @@ mod tests { use std::borrow::Cow; use ruff_python_ast::{ - str::Quote, + str::{Quote, TripleQuotes}, str_prefix::{AnyStringPrefix, ByteStringPrefix}, AnyStringFlags, }; @@ -1086,7 +1087,7 @@ mod tests { AnyStringFlags::new( AnyStringPrefix::Bytes(ByteStringPrefix::Regular), Quote::Double, - false, + TripleQuotes::No, ), false, ); diff --git a/crates/ruff_python_literal/src/escape.rs b/crates/ruff_python_literal/src/escape.rs index 91157b57109ec..5a218dbcfd091 100644 --- a/crates/ruff_python_literal/src/escape.rs +++ b/crates/ruff_python_literal/src/escape.rs @@ -1,4 +1,7 @@ -use ruff_python_ast::str::Quote; +use ruff_python_ast::{ + str::{Quote, TripleQuotes}, + BytesLiteralFlags, StringFlags, StringLiteralFlags, +}; pub struct EscapeLayout { pub quote: Quote, @@ -60,23 +63,32 @@ impl<'a> UnicodeEscape<'a> { Self::with_preferred_quote(source, Quote::Single) } #[inline] - pub fn str_repr<'r>(&'a self) -> StrRepr<'r, 'a> { - StrRepr(self) + pub fn str_repr<'r>(&'a self, triple_quotes: TripleQuotes) -> StrRepr<'r, 'a> { + StrRepr { + escape: self, + triple_quotes, + } } } -pub struct StrRepr<'r, 'a>(&'r UnicodeEscape<'a>); +pub struct StrRepr<'r, 'a> { + escape: &'r UnicodeEscape<'a>, + triple_quotes: TripleQuotes, +} impl StrRepr<'_, '_> { pub fn write(&self, formatter: &mut impl std::fmt::Write) -> std::fmt::Result { - let quote = self.0.layout().quote.as_char(); - formatter.write_char(quote)?; - self.0.write_body(formatter)?; - formatter.write_char(quote) + let flags = StringLiteralFlags::empty() + .with_quote_style(self.escape.layout().quote) + .with_triple_quotes(self.triple_quotes); + formatter.write_str(flags.quote_str())?; + self.escape.write_body(formatter)?; + formatter.write_str(flags.quote_str())?; + Ok(()) } pub fn to_string(&self) -> Option { - let mut s = String::with_capacity(self.0.layout().len?); + let mut s = String::with_capacity(self.escape.layout().len?); self.write(&mut s).unwrap(); Some(s) } @@ -244,8 +256,11 @@ impl<'a> AsciiEscape<'a> { Self::with_preferred_quote(source, Quote::Single) } #[inline] - pub fn bytes_repr<'r>(&'a self) -> BytesRepr<'r, 'a> { - BytesRepr(self) + pub fn bytes_repr<'r>(&'a self, triple_quotes: TripleQuotes) -> BytesRepr<'r, 'a> { + BytesRepr { + escape: self, + triple_quotes, + } } } @@ -360,19 +375,26 @@ impl Escape for AsciiEscape<'_> { } } -pub struct BytesRepr<'r, 'a>(&'r AsciiEscape<'a>); +pub struct BytesRepr<'r, 'a> { + escape: &'r AsciiEscape<'a>, + triple_quotes: TripleQuotes, +} impl BytesRepr<'_, '_> { pub fn write(&self, formatter: &mut impl std::fmt::Write) -> std::fmt::Result { - let quote = self.0.layout().quote.as_char(); + let flags = BytesLiteralFlags::empty() + .with_quote_style(self.escape.layout().quote) + .with_triple_quotes(self.triple_quotes); + formatter.write_char('b')?; - formatter.write_char(quote)?; - self.0.write_body(formatter)?; - formatter.write_char(quote) + formatter.write_str(flags.quote_str())?; + self.escape.write_body(formatter)?; + formatter.write_str(flags.quote_str())?; + Ok(()) } pub fn to_string(&self) -> Option { - let mut s = String::with_capacity(self.0.layout().len?); + let mut s = String::with_capacity(self.escape.layout().len?); self.write(&mut s).unwrap(); Some(s) } diff --git a/crates/ruff_python_parser/src/token.rs b/crates/ruff_python_parser/src/token.rs index 9c240f4964e5b..ecda19730a2d5 100644 --- a/crates/ruff_python_parser/src/token.rs +++ b/crates/ruff_python_parser/src/token.rs @@ -10,7 +10,7 @@ use std::fmt; use bitflags::bitflags; use ruff_python_ast::name::Name; -use ruff_python_ast::str::Quote; +use ruff_python_ast::str::{Quote, TripleQuotes}; use ruff_python_ast::str_prefix::{ AnyStringPrefix, ByteStringPrefix, FStringPrefix, StringLiteralPrefix, }; @@ -718,8 +718,12 @@ impl StringFlags for TokenFlags { } } - fn is_triple_quoted(self) -> bool { - self.intersects(TokenFlags::TRIPLE_QUOTED_STRING) + fn triple_quotes(self) -> TripleQuotes { + if self.intersects(TokenFlags::TRIPLE_QUOTED_STRING) { + TripleQuotes::Yes + } else { + TripleQuotes::No + } } fn prefix(self) -> AnyStringPrefix { @@ -769,7 +773,7 @@ impl TokenFlags { /// Converts this type to [`AnyStringFlags`], setting the equivalent flags. pub(crate) fn as_any_string_flags(self) -> AnyStringFlags { - AnyStringFlags::new(self.prefix(), self.quote_style(), self.is_triple_quoted()) + AnyStringFlags::new(self.prefix(), self.quote_style(), self.triple_quotes()) } }