Preserve triple quotes and prefixes for strings (#15818)

## Summary

This is a follow-up to #15726, #15778, and #15794 to preserve the triple
quote and prefix flags in plain strings, bytestrings, and f-strings.

I also added a `StringLiteralFlags::without_triple_quotes` method to
avoid passing along triple quotes in rules like SIM905 where it might
not make sense, as discussed
[here](https://github.com/astral-sh/ruff/pull/15726#discussion_r1930532426).

## Test Plan

Existing tests, plus many new cases in the `generator::tests::quote`
test that should cover all combinations of quotes and prefixes, at least
for simple string bodies.

Closes #7799 when combined with #15694, #15726, #15778, and #15794.

---------

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
This commit is contained in:
Brent Westbrook 2025-02-04 08:41:06 -05:00 committed by GitHub
parent 9a33924a65
commit b5e5271adf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 318 additions and 141 deletions

1
Cargo.lock generated
View file

@ -2999,6 +2999,7 @@ dependencies = [
"ruff_python_parser", "ruff_python_parser",
"ruff_source_file", "ruff_source_file",
"ruff_text_size", "ruff_text_size",
"test-case",
] ]
[[package]] [[package]]

View file

@ -3,7 +3,7 @@
use std::fmt::{self, Display, Formatter, Write}; use std::fmt::{self, Display, Formatter, Write};
use ruff_db::display::FormatterJoinExtension; 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 ruff_python_literal::escape::AsciiEscape;
use crate::types::class_base::ClassBase; use crate::types::class_base::ClassBase;
@ -98,7 +98,7 @@ impl Display for DisplayRepresentation<'_> {
let escape = let escape =
AsciiEscape::with_preferred_quote(bytes.value(self.db).as_ref(), Quote::Double); 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) => { Type::SliceLiteral(slice) => {
f.write_str("slice[")?; f.write_str("slice[")?;

View file

@ -97,3 +97,12 @@ b"TesT.WwW.ExamplE.CoM".split(b".")
"hello\nworld".splitlines() "hello\nworld".splitlines()
"hello\nworld".splitlines(keepends=True) "hello\nworld".splitlines(keepends=True)
"hello\nworld".splitlines(keepends=False) "hello\nworld".splitlines(keepends=False)
# another positive demonstrating quote preservation
"""
"itemA"
'itemB'
'''itemC'''
"'itemD'"
""".split()

View file

@ -108,7 +108,9 @@ fn check_string_or_bytes(
let mut diagnostic = Diagnostic::new(UnnecessaryEscapedQuote, range); let mut diagnostic = Diagnostic::new(UnnecessaryEscapedQuote, range);
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement( 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, range,
))); )));
Some(diagnostic) Some(diagnostic)

View file

@ -3,8 +3,8 @@ use std::cmp::Ordering;
use ruff_diagnostics::{Applicability, Diagnostic, Edit, Fix, FixAvailability, Violation}; use ruff_diagnostics::{Applicability, Diagnostic, Edit, Fix, FixAvailability, Violation};
use ruff_macros::{derive_message_formats, ViolationMetadata}; use ruff_macros::{derive_message_formats, ViolationMetadata};
use ruff_python_ast::{ use ruff_python_ast::{
Expr, ExprCall, ExprContext, ExprList, ExprUnaryOp, StringLiteral, StringLiteralFlags, str::TripleQuotes, Expr, ExprCall, ExprContext, ExprList, ExprUnaryOp, StringLiteral,
StringLiteralValue, UnaryOp, StringLiteralFlags, StringLiteralValue, UnaryOp,
}; };
use ruff_text_size::{Ranged, TextRange}; use ruff_text_size::{Ranged, TextRange};
@ -123,7 +123,17 @@ fn construct_replacement(elts: &[&str], flags: StringLiteralFlags) -> Expr {
Expr::from(StringLiteral { Expr::from(StringLiteral {
value: Box::from(*elt), value: Box::from(*elt),
range: TextRange::default(), 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(), .collect(),

View file

@ -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"] 72 |+["a,b,c"] # ["a,b,c"]
73 73 | 73 73 |
74 74 | # negatives 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'\""]

View file

@ -405,7 +405,9 @@ pub(crate) fn printf_string_formatting(
// Convert the `%`-format string to a `.format` string. // Convert the `%`-format string to a `.format` string.
format_strings.push(( format_strings.push((
string_literal.range(), string_literal.range(),
flags.format_string_contents(&percent_to_format(&format_string)), flags
.display_contents(&percent_to_format(&format_string))
.to_string(),
)); ));
} }

View file

@ -13,10 +13,10 @@ use itertools::Itertools;
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
use crate::name::Name;
use crate::{ use crate::{
int, int,
str::Quote, name::Name,
str::{Quote, TripleQuotes},
str_prefix::{AnyStringPrefix, ByteStringPrefix, FStringPrefix, StringLiteralPrefix}, str_prefix::{AnyStringPrefix, ByteStringPrefix, FStringPrefix, StringLiteralPrefix},
ExceptHandler, Expr, FStringElement, LiteralExpressionRef, Pattern, Stmt, TypeParam, 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? /// Does the string use single or double quotes in its opener and closer?
fn quote_style(self) -> Quote; fn quote_style(self) -> Quote;
/// Is the string triple-quoted, i.e., fn triple_quotes(self) -> TripleQuotes;
/// does it begin and end with three consecutive quote characters?
fn is_triple_quoted(self) -> bool;
fn prefix(self) -> AnyStringPrefix; 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. /// A `str` representation of the quotes used to start and close.
/// This does not include any prefixes the string has in its opener. /// This does not include any prefixes the string has in its opener.
fn quote_str(self) -> &'static str { fn quote_str(self) -> &'static str {
if self.is_triple_quoted() { match (self.triple_quotes(), self.quote_style()) {
match self.quote_style() { (TripleQuotes::Yes, Quote::Single) => "'''",
Quote::Single => "'''", (TripleQuotes::Yes, Quote::Double) => r#"""""#,
Quote::Double => r#"""""#, (TripleQuotes::No, Quote::Single) => "'",
} (TripleQuotes::No, Quote::Double) => "\"",
} else {
match self.quote_style() {
Quote::Single => "'",
Quote::Double => "\"",
}
} }
} }
@ -1028,10 +1027,30 @@ pub trait StringFlags: Copy {
self.quote_len() self.quote_len()
} }
fn format_string_contents(self, contents: &str) -> String { fn display_contents(self, contents: &str) -> DisplayFlags {
let prefix = self.prefix(); DisplayFlags {
let quote_str = self.quote_str(); prefix: self.prefix(),
format!("{prefix}{quote_str}{contents}{quote_str}") 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] #[must_use]
pub fn with_triple_quotes(mut self) -> Self { pub fn with_triple_quotes(mut self, triple_quotes: TripleQuotes) -> Self {
self.0 |= FStringFlagsInner::TRIPLE_QUOTED; self.0
.set(FStringFlagsInner::TRIPLE_QUOTED, triple_quotes.is_yes());
self self
} }
@ -1132,8 +1152,12 @@ impl StringFlags for FStringFlags {
/// Return `true` if the f-string is triple-quoted, i.e., /// Return `true` if the f-string is triple-quoted, i.e.,
/// it begins and ends with three consecutive quote characters. /// it begins and ends with three consecutive quote characters.
/// For example: `f"""{bar}"""` /// For example: `f"""{bar}"""`
fn is_triple_quoted(self) -> bool { fn triple_quotes(self) -> TripleQuotes {
self.0.contains(FStringFlagsInner::TRIPLE_QUOTED) if self.0.contains(FStringFlagsInner::TRIPLE_QUOTED) {
TripleQuotes::Yes
} else {
TripleQuotes::No
}
} }
/// Return the quoting style (single or double quotes) /// Return the quoting style (single or double quotes)
@ -1477,8 +1501,11 @@ impl StringLiteralFlags {
} }
#[must_use] #[must_use]
pub fn with_triple_quotes(mut self) -> Self { pub fn with_triple_quotes(mut self, triple_quotes: TripleQuotes) -> Self {
self.0 |= StringLiteralFlagsInner::TRIPLE_QUOTED; self.0.set(
StringLiteralFlagsInner::TRIPLE_QUOTED,
triple_quotes.is_yes(),
);
self self
} }
@ -1550,8 +1577,12 @@ impl StringFlags for StringLiteralFlags {
/// Return `true` if the string is triple-quoted, i.e., /// Return `true` if the string is triple-quoted, i.e.,
/// it begins and ends with three consecutive quote characters. /// it begins and ends with three consecutive quote characters.
/// For example: `"""bar"""` /// For example: `"""bar"""`
fn is_triple_quoted(self) -> bool { fn triple_quotes(self) -> TripleQuotes {
self.0.contains(StringLiteralFlagsInner::TRIPLE_QUOTED) if self.0.contains(StringLiteralFlagsInner::TRIPLE_QUOTED) {
TripleQuotes::Yes
} else {
TripleQuotes::No
}
} }
fn prefix(self) -> AnyStringPrefix { fn prefix(self) -> AnyStringPrefix {
@ -1866,8 +1897,11 @@ impl BytesLiteralFlags {
} }
#[must_use] #[must_use]
pub fn with_triple_quotes(mut self) -> Self { pub fn with_triple_quotes(mut self, triple_quotes: TripleQuotes) -> Self {
self.0 |= BytesLiteralFlagsInner::TRIPLE_QUOTED; self.0.set(
BytesLiteralFlagsInner::TRIPLE_QUOTED,
triple_quotes.is_yes(),
);
self self
} }
@ -1910,8 +1944,12 @@ impl StringFlags for BytesLiteralFlags {
/// Return `true` if the bytestring is triple-quoted, i.e., /// Return `true` if the bytestring is triple-quoted, i.e.,
/// it begins and ends with three consecutive quote characters. /// it begins and ends with three consecutive quote characters.
/// For example: `b"""{bar}"""` /// For example: `b"""{bar}"""`
fn is_triple_quoted(self) -> bool { fn triple_quotes(self) -> TripleQuotes {
self.0.contains(BytesLiteralFlagsInner::TRIPLE_QUOTED) if self.0.contains(BytesLiteralFlagsInner::TRIPLE_QUOTED) {
TripleQuotes::Yes
} else {
TripleQuotes::No
}
} }
/// Return the quoting style (single or double quotes) /// 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); pub struct AnyStringFlags(AnyStringFlagsInner);
impl AnyStringFlags { impl AnyStringFlags {
@ -2073,13 +2111,11 @@ impl AnyStringFlags {
self self
} }
pub fn new(prefix: AnyStringPrefix, quotes: Quote, triple_quoted: bool) -> Self { pub fn new(prefix: AnyStringPrefix, quotes: Quote, triple_quotes: TripleQuotes) -> Self {
let new = Self::default().with_prefix(prefix).with_quote_style(quotes); Self(AnyStringFlagsInner::empty())
if triple_quoted { .with_prefix(prefix)
new.with_triple_quotes() .with_quote_style(quotes)
} else { .with_triple_quotes(triple_quotes)
new
}
} }
/// Does the string have a `u` or `U` prefix? /// Does the string have a `u` or `U` prefix?
@ -2114,8 +2150,9 @@ impl AnyStringFlags {
} }
#[must_use] #[must_use]
pub fn with_triple_quotes(mut self) -> Self { pub fn with_triple_quotes(mut self, triple_quotes: TripleQuotes) -> Self {
self.0 |= AnyStringFlagsInner::TRIPLE_QUOTED; self.0
.set(AnyStringFlagsInner::TRIPLE_QUOTED, triple_quotes.is_yes());
self self
} }
} }
@ -2130,10 +2167,12 @@ impl StringFlags for AnyStringFlags {
} }
} }
/// Is the string triple-quoted, i.e., fn triple_quotes(self) -> TripleQuotes {
/// does it begin and end with three consecutive quote characters? if self.0.contains(AnyStringFlagsInner::TRIPLE_QUOTED) {
fn is_triple_quoted(self) -> bool { TripleQuotes::Yes
self.0.contains(AnyStringFlagsInner::TRIPLE_QUOTED) } else {
TripleQuotes::No
}
} }
fn prefix(self) -> AnyStringPrefix { fn prefix(self) -> AnyStringPrefix {
@ -2193,14 +2232,10 @@ impl From<AnyStringFlags> for StringLiteralFlags {
value.prefix() value.prefix()
) )
}; };
let new = StringLiteralFlags::empty() StringLiteralFlags::empty()
.with_quote_style(value.quote_style()) .with_quote_style(value.quote_style())
.with_prefix(prefix); .with_prefix(prefix)
if value.is_triple_quoted() { .with_triple_quotes(value.triple_quotes())
new.with_triple_quotes()
} else {
new
}
} }
} }
@ -2209,7 +2244,7 @@ impl From<StringLiteralFlags> for AnyStringFlags {
Self::new( Self::new(
AnyStringPrefix::Regular(value.prefix()), AnyStringPrefix::Regular(value.prefix()),
value.quote_style(), value.quote_style(),
value.is_triple_quoted(), value.triple_quotes(),
) )
} }
} }
@ -2222,14 +2257,10 @@ impl From<AnyStringFlags> for BytesLiteralFlags {
value.prefix() value.prefix()
) )
}; };
let new = BytesLiteralFlags::empty() BytesLiteralFlags::empty()
.with_quote_style(value.quote_style()) .with_quote_style(value.quote_style())
.with_prefix(bytestring_prefix); .with_prefix(bytestring_prefix)
if value.is_triple_quoted() { .with_triple_quotes(value.triple_quotes())
new.with_triple_quotes()
} else {
new
}
} }
} }
@ -2238,7 +2269,7 @@ impl From<BytesLiteralFlags> for AnyStringFlags {
Self::new( Self::new(
AnyStringPrefix::Bytes(value.prefix()), AnyStringPrefix::Bytes(value.prefix()),
value.quote_style(), value.quote_style(),
value.is_triple_quoted(), value.triple_quotes(),
) )
} }
} }
@ -2251,14 +2282,10 @@ impl From<AnyStringFlags> for FStringFlags {
value.prefix() value.prefix()
) )
}; };
let new = FStringFlags::empty() FStringFlags::empty()
.with_quote_style(value.quote_style()) .with_quote_style(value.quote_style())
.with_prefix(fstring_prefix); .with_prefix(fstring_prefix)
if value.is_triple_quoted() { .with_triple_quotes(value.triple_quotes())
new.with_triple_quotes()
} else {
new
}
} }
} }
@ -2267,7 +2294,7 @@ impl From<FStringFlags> for AnyStringFlags {
Self::new( Self::new(
AnyStringPrefix::Format(value.prefix()), AnyStringPrefix::Format(value.prefix()),
value.quote_style(), value.quote_style(),
value.is_triple_quoted(), value.triple_quotes(),
) )
} }
} }

View file

@ -68,6 +68,24 @@ impl TryFrom<char> 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 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. /// includes all possible orders, and all possible casings, for both single and triple quotes.
/// ///

View file

@ -20,6 +20,8 @@ ruff_python_parser = { workspace = true }
ruff_source_file = { workspace = true } ruff_source_file = { workspace = true }
ruff_text_size = { workspace = true } ruff_text_size = { workspace = true }
[dev-dependencies]
test-case = { workspace = true }
[lints] [lints]
workspace = true workspace = true

View file

@ -1,13 +1,13 @@
//! Generate Python source code from an abstract syntax tree (AST). //! Generate Python source code from an abstract syntax tree (AST).
use std::fmt::Write;
use std::ops::Deref; use std::ops::Deref;
use ruff_python_ast::str::Quote;
use ruff_python_ast::{ use ruff_python_ast::{
self as ast, Alias, ArgOrKeyword, BoolOp, CmpOp, Comprehension, ConversionFlag, DebugText, self as ast, Alias, AnyStringFlags, ArgOrKeyword, BoolOp, BytesLiteralFlags, CmpOp,
ExceptHandler, Expr, Identifier, MatchCase, Operator, Parameter, Parameters, Pattern, Comprehension, ConversionFlag, DebugText, ExceptHandler, Expr, FStringFlags, Identifier,
Singleton, Stmt, StringFlags, Suite, TypeParam, TypeParamParamSpec, TypeParamTypeVar, MatchCase, Operator, Parameter, Parameters, Pattern, Singleton, Stmt, StringFlags, Suite,
TypeParamTypeVarTuple, WithItem, TypeParam, TypeParamParamSpec, TypeParamTypeVar, TypeParamTypeVarTuple, WithItem,
}; };
use ruff_python_ast::{ParameterWithDefault, TypeParams}; use ruff_python_ast::{ParameterWithDefault, TypeParams};
use ruff_python_literal::escape::{AsciiEscape, Escape, UnicodeEscape}; use ruff_python_literal::escape::{AsciiEscape, Escape, UnicodeEscape};
@ -146,20 +146,44 @@ impl<'a> Generator<'a> {
self.p(s.as_str()); self.p(s.as_str());
} }
fn p_bytes_repr(&mut self, s: &[u8], quote: Quote) { fn p_bytes_repr(&mut self, s: &[u8], flags: BytesLiteralFlags) {
let escape = AsciiEscape::with_preferred_quote(s, quote); // 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 { if let Some(len) = escape.layout().len {
self.buffer.reserve(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) { fn p_str_repr(&mut self, s: &str, flags: impl Into<AnyStringFlags>) {
let escape = UnicodeEscape::with_preferred_quote(s, quote); 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 { if let Some(len) = escape.layout().len {
self.buffer.reserve(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) { fn p_if(&mut self, cond: bool, s: &str) {
@ -1093,7 +1117,7 @@ impl<'a> Generator<'a> {
let mut first = true; let mut first = true;
for bytes_literal in value { for bytes_literal in value {
self.p_delim(&mut first, " "); 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, .. }) => { Expr::NumberLiteral(ast::ExprNumberLiteral { value, .. }) => {
@ -1280,19 +1304,7 @@ impl<'a> Generator<'a> {
fn unparse_string_literal(&mut self, string_literal: &ast::StringLiteral) { fn unparse_string_literal(&mut self, string_literal: &ast::StringLiteral) {
let ast::StringLiteral { value, flags, .. } = string_literal; 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 self.p_str_repr(value, *flags);
// 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());
}
} }
fn unparse_string_literal_value(&mut self, value: &ast::StringLiteralValue) { fn unparse_string_literal_value(&mut self, value: &ast::StringLiteralValue) {
@ -1312,7 +1324,7 @@ impl<'a> Generator<'a> {
self.unparse_string_literal(string_literal); self.unparse_string_literal(string_literal);
} }
ast::FStringPart::FString(f_string) => { 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 /// Unparse `values` with [`Generator::unparse_f_string_body`], using `quote` as the preferred
/// surrounding quote style. /// surrounding quote style.
fn unparse_f_string(&mut self, values: &[ast::FStringElement], quote: Quote) { fn unparse_f_string(&mut self, values: &[ast::FStringElement], flags: FStringFlags) {
self.p("f");
let mut generator = Generator::new(self.indent, self.line_ending); let mut generator = Generator::new(self.indent, self.line_ending);
generator.unparse_f_string_body(values); generator.unparse_f_string_body(values);
let body = &generator.buffer; let body = &generator.buffer;
self.p_str_repr(body, quote); self.p_str_repr(body, flags);
} }
fn unparse_alias(&mut self, alias: &Alias) { fn unparse_alias(&mut self, alias: &Alias) {
@ -1724,10 +1735,53 @@ class Foo:
assert_round_trip!(r#"f"hello""#); assert_round_trip!(r#"f"hello""#);
assert_eq!(round_trip(r#"("abc" "def" "ghi")"#), r#""abc" "def" "ghi""#); 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#""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_eq!(round_trip(r#"f"abc{'def'}{1}""#), r#"f"abc{'def'}{1}""#);
assert_round_trip!(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] #[test]
fn raw() { fn raw() {
assert_round_trip!(r#"r"a\.b""#); // https://github.com/astral-sh/ruff/issues/9663 assert_round_trip!(r#"r"a\.b""#); // https://github.com/astral-sh/ruff/issues/9663

View file

@ -1,6 +1,6 @@
use itertools::Itertools; use itertools::Itertools;
use ruff_formatter::{format_args, write, FormatContext}; 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::{ use ruff_python_ast::str_prefix::{
AnyStringPrefix, ByteStringPrefix, FStringPrefix, StringLiteralPrefix, 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() { if !string.is_implicit_concatenated() {

View file

@ -1,6 +1,6 @@
use memchr::memchr2; use memchr::memchr2;
pub(crate) use normalize::{normalize_string, NormalizedString, StringNormalizer}; 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::StringLikePart;
use ruff_python_ast::{ use ruff_python_ast::{
self as ast, self as ast,
@ -95,11 +95,11 @@ impl StringLikeExtensions for ast::StringLike<'_> {
fn contains_line_break_or_comments( fn contains_line_break_or_comments(
elements: &ast::FStringElements, elements: &ast::FStringElements,
context: &PyFormatContext, context: &PyFormatContext,
is_triple_quoted: bool, triple_quotes: TripleQuotes,
) -> bool { ) -> bool {
elements.iter().any(|element| match element { elements.iter().any(|element| match element {
ast::FStringElement::Literal(literal) => { ast::FStringElement::Literal(literal) => {
is_triple_quoted triple_quotes.is_yes()
&& context.source().contains_line_break(literal.range()) && context.source().contains_line_break(literal.range())
} }
ast::FStringElement::Expression(expression) => { ast::FStringElement::Expression(expression) => {
@ -119,7 +119,7 @@ impl StringLikeExtensions for ast::StringLike<'_> {
contains_line_break_or_comments( contains_line_break_or_comments(
&spec.elements, &spec.elements,
context, context,
is_triple_quoted, triple_quotes,
) )
}) })
|| expression.debug_text.as_ref().is_some_and(|debug_text| { || expression.debug_text.as_ref().is_some_and(|debug_text| {
@ -134,7 +134,7 @@ impl StringLikeExtensions for ast::StringLike<'_> {
contains_line_break_or_comments( contains_line_break_or_comments(
&f_string.elements, &f_string.elements,
context, context,
f_string.flags.is_triple_quoted(), f_string.flags.triple_quotes(),
) )
} }
}) })

View file

@ -5,8 +5,9 @@ use std::iter::FusedIterator;
use ruff_formatter::FormatContext; use ruff_formatter::FormatContext;
use ruff_python_ast::visitor::source_order::SourceOrderVisitor; use ruff_python_ast::visitor::source_order::SourceOrderVisitor;
use ruff_python_ast::{ use ruff_python_ast::{
str::Quote, AnyStringFlags, BytesLiteral, FString, FStringElement, FStringElements, str::{Quote, TripleQuotes},
FStringFlags, StringFlags, StringLikePart, StringLiteral, AnyStringFlags, BytesLiteral, FString, FStringElement, FStringElements, FStringFlags,
StringFlags, StringLikePart, StringLiteral,
}; };
use ruff_text_size::{Ranged, TextRange, TextSlice}; 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 { pub(crate) fn from_str(text: &str, flags: AnyStringFlags, preferred_quote: Quote) -> Self {
let kind = if flags.is_raw_string() { 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() { } else if flags.is_triple_quoted() {
QuoteMetadataKind::triple_quoted(text, preferred_quote) QuoteMetadataKind::triple_quoted(text, preferred_quote)
} else { } else {
@ -528,7 +529,7 @@ impl QuoteMetadataKind {
/// Computes if a raw string uses the preferred quote. If it does, then it's not possible /// 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. /// 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 mut chars = text.chars().peekable();
let preferred_quote_char = preferred.as_char(); let preferred_quote_char = preferred.as_char();
@ -540,7 +541,7 @@ impl QuoteMetadataKind {
} }
// `"` or `'` // `"` or `'`
Some(c) if c == preferred_quote_char => { Some(c) if c == preferred_quote_char => {
if !triple_quoted { if triple_quotes.is_no() {
break true; break true;
} }
@ -1057,7 +1058,7 @@ mod tests {
use std::borrow::Cow; use std::borrow::Cow;
use ruff_python_ast::{ use ruff_python_ast::{
str::Quote, str::{Quote, TripleQuotes},
str_prefix::{AnyStringPrefix, ByteStringPrefix}, str_prefix::{AnyStringPrefix, ByteStringPrefix},
AnyStringFlags, AnyStringFlags,
}; };
@ -1086,7 +1087,7 @@ mod tests {
AnyStringFlags::new( AnyStringFlags::new(
AnyStringPrefix::Bytes(ByteStringPrefix::Regular), AnyStringPrefix::Bytes(ByteStringPrefix::Regular),
Quote::Double, Quote::Double,
false, TripleQuotes::No,
), ),
false, false,
); );

View file

@ -1,4 +1,7 @@
use ruff_python_ast::str::Quote; use ruff_python_ast::{
str::{Quote, TripleQuotes},
BytesLiteralFlags, StringFlags, StringLiteralFlags,
};
pub struct EscapeLayout { pub struct EscapeLayout {
pub quote: Quote, pub quote: Quote,
@ -60,23 +63,32 @@ impl<'a> UnicodeEscape<'a> {
Self::with_preferred_quote(source, Quote::Single) Self::with_preferred_quote(source, Quote::Single)
} }
#[inline] #[inline]
pub fn str_repr<'r>(&'a self) -> StrRepr<'r, 'a> { pub fn str_repr<'r>(&'a self, triple_quotes: TripleQuotes) -> StrRepr<'r, 'a> {
StrRepr(self) 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<'_, '_> { impl StrRepr<'_, '_> {
pub fn write(&self, formatter: &mut impl std::fmt::Write) -> std::fmt::Result { pub fn write(&self, formatter: &mut impl std::fmt::Write) -> std::fmt::Result {
let quote = self.0.layout().quote.as_char(); let flags = StringLiteralFlags::empty()
formatter.write_char(quote)?; .with_quote_style(self.escape.layout().quote)
self.0.write_body(formatter)?; .with_triple_quotes(self.triple_quotes);
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<String> { pub fn to_string(&self) -> Option<String> {
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(); self.write(&mut s).unwrap();
Some(s) Some(s)
} }
@ -244,8 +256,11 @@ impl<'a> AsciiEscape<'a> {
Self::with_preferred_quote(source, Quote::Single) Self::with_preferred_quote(source, Quote::Single)
} }
#[inline] #[inline]
pub fn bytes_repr<'r>(&'a self) -> BytesRepr<'r, 'a> { pub fn bytes_repr<'r>(&'a self, triple_quotes: TripleQuotes) -> BytesRepr<'r, 'a> {
BytesRepr(self) 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<'_, '_> { impl BytesRepr<'_, '_> {
pub fn write(&self, formatter: &mut impl std::fmt::Write) -> std::fmt::Result { 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('b')?;
formatter.write_char(quote)?; formatter.write_str(flags.quote_str())?;
self.0.write_body(formatter)?; self.escape.write_body(formatter)?;
formatter.write_char(quote) formatter.write_str(flags.quote_str())?;
Ok(())
} }
pub fn to_string(&self) -> Option<String> { pub fn to_string(&self) -> Option<String> {
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(); self.write(&mut s).unwrap();
Some(s) Some(s)
} }

View file

@ -10,7 +10,7 @@ use std::fmt;
use bitflags::bitflags; use bitflags::bitflags;
use ruff_python_ast::name::Name; 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::{ use ruff_python_ast::str_prefix::{
AnyStringPrefix, ByteStringPrefix, FStringPrefix, StringLiteralPrefix, AnyStringPrefix, ByteStringPrefix, FStringPrefix, StringLiteralPrefix,
}; };
@ -718,8 +718,12 @@ impl StringFlags for TokenFlags {
} }
} }
fn is_triple_quoted(self) -> bool { fn triple_quotes(self) -> TripleQuotes {
self.intersects(TokenFlags::TRIPLE_QUOTED_STRING) if self.intersects(TokenFlags::TRIPLE_QUOTED_STRING) {
TripleQuotes::Yes
} else {
TripleQuotes::No
}
} }
fn prefix(self) -> AnyStringPrefix { fn prefix(self) -> AnyStringPrefix {
@ -769,7 +773,7 @@ impl TokenFlags {
/// Converts this type to [`AnyStringFlags`], setting the equivalent flags. /// Converts this type to [`AnyStringFlags`], setting the equivalent flags.
pub(crate) fn as_any_string_flags(self) -> AnyStringFlags { 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())
} }
} }