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_source_file",
"ruff_text_size",
"test-case",
]
[[package]]

View file

@ -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[")?;

View file

@ -97,3 +97,12 @@ b"TesT.WwW.ExamplE.CoM".split(b".")
"hello\nworld".splitlines()
"hello\nworld".splitlines(keepends=True)
"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);
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)

View file

@ -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(),

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"]
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'\""]

View file

@ -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(),
));
}

View file

@ -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<AnyStringFlags> 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<StringLiteralFlags> for AnyStringFlags {
Self::new(
AnyStringPrefix::Regular(value.prefix()),
value.quote_style(),
value.is_triple_quoted(),
value.triple_quotes(),
)
}
}
@ -2222,14 +2257,10 @@ impl From<AnyStringFlags> 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<BytesLiteralFlags> for AnyStringFlags {
Self::new(
AnyStringPrefix::Bytes(value.prefix()),
value.quote_style(),
value.is_triple_quoted(),
value.triple_quotes(),
)
}
}
@ -2251,14 +2282,10 @@ impl From<AnyStringFlags> 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<FStringFlags> for AnyStringFlags {
Self::new(
AnyStringPrefix::Format(value.prefix()),
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 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_text_size = { workspace = true }
[dev-dependencies]
test-case = { workspace = true }
[lints]
workspace = true

View file

@ -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<AnyStringFlags>) {
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

View file

@ -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() {

View file

@ -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(),
)
}
})

View file

@ -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,
);

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 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<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();
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<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();
Some(s)
}

View file

@ -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())
}
}