Preserve quotes in generated f-strings (#15794)

## Summary

This is another follow-up to #15726 and #15778, extending the
quote-preserving behavior to f-strings and deleting the now-unused
`Generator::quote` field.

## Details
I also made one unrelated change to `rules/flynt/helpers.rs` to remove a
`to_string` call for making a `Box<str>` and tweaked some arguments to
some of the `Generator::unparse_f_string` methods to make the code
easier to follow, in my opinion. Happy to revert especially the latter
of these if needed.

Unfortunately this still does not fix the issue in #9660, which appears
to be more of an escaping issue than a quote-preservation issue. After
#15726, the result is now `a = f'# {"".join([])}' if 1 else ""` instead
of `a = f"# {''.join([])}" if 1 else ""` (single quotes on the outside
now), but we still don't have the desired behavior of double quotes
everywhere on Python 3.12+. I added a test for this but split it off
into another branch since it ended up being unaddressed here, but my
`dbg!` statements showed the correct preferred quotes going into
[`UnicodeEscape::with_preferred_quote`](https://github.com/astral-sh/ruff/blob/main/crates/ruff_python_literal/src/escape.rs#L54).

## Test Plan

Existing rule and `Generator` tests.

---------

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
This commit is contained in:
Brent Westbrook 2025-01-29 13:28:22 -05:00 committed by GitHub
parent d151ca85d3
commit 23c98849fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 197 additions and 116 deletions

View file

@ -65,11 +65,6 @@ mod precedence {
pub struct Generator<'a> {
/// The indentation style to use.
indent: &'a Indentation,
/// The quote style to use for bytestring and f-string literals. For a plain
/// [`StringLiteral`](ast::StringLiteral), modify its `flags` field using
/// [`StringLiteralFlags::with_quote_style`](ast::StringLiteralFlags::with_quote_style) before
/// passing it to the [`Generator`].
quote: Quote,
/// The line ending to use.
line_ending: LineEnding,
buffer: String,
@ -82,7 +77,6 @@ impl<'a> From<&'a Stylist<'a>> for Generator<'a> {
fn from(stylist: &'a Stylist<'a>) -> Self {
Self {
indent: stylist.indentation(),
quote: stylist.quote(),
line_ending: stylist.line_ending(),
buffer: String::new(),
indent_depth: 0,
@ -93,11 +87,10 @@ impl<'a> From<&'a Stylist<'a>> for Generator<'a> {
}
impl<'a> Generator<'a> {
pub const fn new(indent: &'a Indentation, quote: Quote, line_ending: LineEnding) -> Self {
pub const fn new(indent: &'a Indentation, line_ending: LineEnding) -> Self {
Self {
// Style preferences.
indent,
quote,
line_ending,
// Internal state.
buffer: String::new(),
@ -1091,7 +1084,7 @@ impl<'a> Generator<'a> {
self.p(")");
}
Expr::FString(ast::ExprFString { value, .. }) => {
self.unparse_f_string_value(value, false);
self.unparse_f_string_value(value);
}
Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) => {
self.unparse_string_literal_value(value);
@ -1310,7 +1303,7 @@ impl<'a> Generator<'a> {
}
}
fn unparse_f_string_value(&mut self, value: &ast::FStringValue, is_spec: bool) {
fn unparse_f_string_value(&mut self, value: &ast::FStringValue) {
let mut first = true;
for f_string_part in value {
self.p_delim(&mut first, " ");
@ -1319,7 +1312,7 @@ impl<'a> Generator<'a> {
self.unparse_string_literal(string_literal);
}
ast::FStringPart::FString(f_string) => {
self.unparse_f_string(&f_string.elements, is_spec);
self.unparse_f_string(&f_string.elements, f_string.flags.quote_style());
}
}
}
@ -1338,7 +1331,7 @@ impl<'a> Generator<'a> {
conversion: ConversionFlag,
spec: Option<&ast::FStringFormatSpec>,
) {
let mut generator = Generator::new(self.indent, self.quote, self.line_ending);
let mut generator = Generator::new(self.indent, self.line_ending);
generator.unparse_expr(val, precedence::FORMATTED_VALUE);
let brace = if generator.buffer.starts_with('{') {
// put a space to avoid escaping the bracket
@ -1366,7 +1359,7 @@ impl<'a> Generator<'a> {
if let Some(spec) = spec {
self.p(":");
self.unparse_f_string(&spec.elements, true);
self.unparse_f_string_specifier(&spec.elements);
}
self.p("}");
@ -1397,17 +1390,18 @@ impl<'a> Generator<'a> {
self.p(&s);
}
fn unparse_f_string(&mut self, values: &[ast::FStringElement], is_spec: bool) {
if is_spec {
self.unparse_f_string_body(values);
} else {
self.p("f");
let mut generator =
Generator::new(self.indent, self.quote.opposite(), self.line_ending);
generator.unparse_f_string_body(values);
let body = &generator.buffer;
self.p_str_repr(body, self.quote);
}
fn unparse_f_string_specifier(&mut self, values: &[ast::FStringElement]) {
self.unparse_f_string_body(values);
}
/// 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");
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);
}
fn unparse_alias(&mut self, alias: &Alias) {
@ -1429,7 +1423,7 @@ impl<'a> Generator<'a> {
#[cfg(test)]
mod tests {
use ruff_python_ast::{str::Quote, Mod, ModModule};
use ruff_python_ast::{Mod, ModModule};
use ruff_python_parser::{self, parse_module, Mode};
use ruff_source_file::LineEnding;
@ -1439,47 +1433,28 @@ mod tests {
fn round_trip(contents: &str) -> String {
let indentation = Indentation::default();
let quote = Quote::default();
let line_ending = LineEnding::default();
let module = parse_module(contents).unwrap();
let mut generator = Generator::new(&indentation, quote, line_ending);
let mut generator = Generator::new(&indentation, line_ending);
generator.unparse_suite(module.suite());
generator.generate()
}
/// Like [`round_trip`] but configure the [`Generator`] with the requested `indentation`,
/// `quote`, and `line_ending` settings.
///
/// Note that quoting styles for string literals are taken from their [`StringLiteralFlags`],
/// not from the [`Generator`] itself, so using this function on a plain string literal can give
/// surprising results.
///
/// ```rust
/// assert_eq!(
/// round_trip_with(
/// &Indentation::default(),
/// Quote::Double,
/// LineEnding::default(),
/// r#"'hello'"#
/// ),
/// r#"'hello'"#
/// );
/// ```
/// Like [`round_trip`] but configure the [`Generator`] with the requested `indentation` and
/// `line_ending` settings.
fn round_trip_with(
indentation: &Indentation,
quote: Quote,
line_ending: LineEnding,
contents: &str,
) -> String {
let module = parse_module(contents).unwrap();
let mut generator = Generator::new(indentation, quote, line_ending);
let mut generator = Generator::new(indentation, line_ending);
generator.unparse_suite(module.suite());
generator.generate()
}
fn jupyter_round_trip(contents: &str) -> String {
let indentation = Indentation::default();
let quote = Quote::default();
let line_ending = LineEnding::default();
let parsed = ruff_python_parser::parse(contents, Mode::Ipython).unwrap();
let Mod::Module(ModModule { body, .. }) = parsed.into_syntax() else {
@ -1488,7 +1463,7 @@ mod tests {
let [stmt] = body.as_slice() else {
panic!("Expected only one statement in source code")
};
let mut generator = Generator::new(&indentation, quote, line_ending);
let mut generator = Generator::new(&indentation, line_ending);
generator.unparse_stmt(stmt);
generator.generate()
}
@ -1744,6 +1719,9 @@ class Foo:
assert_round_trip!(r"u'hello'");
assert_round_trip!(r"r'hello'");
assert_round_trip!(r"b'hello'");
assert_round_trip!(r#"b"hello""#);
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#""he\"llo""#), r#"'he"llo'"#);
assert_eq!(round_trip(r#"f"abc{'def'}{1}""#), r#"f"abc{'def'}{1}""#);
@ -1792,50 +1770,11 @@ if True:
);
}
#[test]
fn set_quote() {
macro_rules! round_trip_with {
($quote:expr, $start:expr, $end:expr) => {
assert_eq!(
round_trip_with(
&Indentation::default(),
$quote,
LineEnding::default(),
$start
),
$end,
);
};
($quote:expr, $start:expr) => {
round_trip_with!($quote, $start, $start);
};
}
// setting Generator::quote works for f-strings
round_trip_with!(Quote::Double, r#"f"hello""#, r#"f"hello""#);
round_trip_with!(Quote::Single, r#"f"hello""#, r"f'hello'");
round_trip_with!(Quote::Double, r"f'hello'", r#"f"hello""#);
round_trip_with!(Quote::Single, r"f'hello'", r"f'hello'");
// but not for bytestrings
round_trip_with!(Quote::Double, r#"b"hello""#);
round_trip_with!(Quote::Single, r#"b"hello""#); // no effect
round_trip_with!(Quote::Double, r"b'hello'"); // no effect
round_trip_with!(Quote::Single, r"b'hello'");
// or for string literals, where the `Quote` is taken directly from their flags
round_trip_with!(Quote::Double, r#""hello""#);
round_trip_with!(Quote::Single, r#""hello""#); // no effect
round_trip_with!(Quote::Double, r"'hello'"); // no effect
round_trip_with!(Quote::Single, r"'hello'");
}
#[test]
fn set_indent() {
assert_eq!(
round_trip_with(
&Indentation::new(" ".to_string()),
Quote::default(),
LineEnding::default(),
r"
if True:
@ -1853,7 +1792,6 @@ if True:
assert_eq!(
round_trip_with(
&Indentation::new(" ".to_string()),
Quote::default(),
LineEnding::default(),
r"
if True:
@ -1871,7 +1809,6 @@ if True:
assert_eq!(
round_trip_with(
&Indentation::new("\t".to_string()),
Quote::default(),
LineEnding::default(),
r"
if True:
@ -1893,7 +1830,6 @@ if True:
assert_eq!(
round_trip_with(
&Indentation::default(),
Quote::default(),
LineEnding::Lf,
"if True:\n print(42)",
),
@ -1903,7 +1839,6 @@ if True:
assert_eq!(
round_trip_with(
&Indentation::default(),
Quote::default(),
LineEnding::CrLf,
"if True:\n print(42)",
),
@ -1913,7 +1848,6 @@ if True:
assert_eq!(
round_trip_with(
&Indentation::default(),
Quote::default(),
LineEnding::Cr,
"if True:\n print(42)",
),