diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP032_0.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP032_0.py index 3214801237..5eb899c3b9 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP032_0.py +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP032_0.py @@ -276,5 +276,8 @@ if __name__ == "__main__": string = "{}".format(number := number + 1) print(string) -# Unicode escape +# Unicode escape in regular string, should convert. "\N{angle}AOB = {angle}°".format(angle=180) + +# Unicode escape in raw string, should not convert - would change semantics. +r"\N{angle}AOB = {angle}°".format(angle=180) diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs index 9749969691..5bc9018124 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs @@ -229,6 +229,9 @@ enum FStringConversion { /// The format call uses arguments with side effects which are repeated within the /// format string. For example: `"{x} {x}".format(x=foo())`. SideEffects, + /// The format string is a raw string containing `\N{...}` which would be + /// misinterpreted in an f-string. + UnsafeRawString, /// The format string should be converted to an f-string. Convert(String), } @@ -286,6 +289,15 @@ impl FStringConversion { return Ok(Self::NonEmptyLiteral); } + // `\N{...}` is literal in raw strings but becomes a Unicode escape in f-strings. + if raw + && format_string.format_parts.iter().any( + |part| matches!(part, FormatPart::Literal(literal) if literal.contains("\\N{")), + ) + { + return Ok(Self::UnsafeRawString); + } + let mut converted = String::with_capacity(contents.len()); let mut seen = FxHashSet::default(); for part in format_string.format_parts { @@ -416,6 +428,7 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma let mut patches: Vec<(TextRange, FStringConversion)> = vec![]; let mut tokens = checker.tokens().in_range(call.func.range()).iter(); + let mut unsafe_conversion = false; let end = loop { let Some(token) = tokens.next() else { unreachable!("Should break from the `Tok::Dot` arm"); @@ -441,6 +454,11 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma // If the format string contains side effects that would need to be repeated, // we can't convert it to an f-string. Ok(FStringConversion::SideEffects) => return, + // If the format string is a raw string with `\N{...}`, conversion would be unsafe. + // We still want to emit the diagnostic, but without offering a fix. + Ok(FStringConversion::UnsafeRawString) => { + unsafe_conversion = true; + } // If any of the segments fail to convert, then we can't convert the entire // expression. Err(_) => return, @@ -451,7 +469,7 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma _ => {} } }; - if patches.is_empty() { + if patches.is_empty() && !unsafe_conversion { return; } @@ -466,7 +484,7 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma Some(curly_unescape(checker.locator().slice(range)).to_string()) } // We handled this in the previous loop. - FStringConversion::SideEffects => unreachable!(), + FStringConversion::SideEffects | FStringConversion::UnsafeRawString => unreachable!(), }; if let Some(fstring) = fstring { contents.push_str( @@ -520,7 +538,7 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma // ``` let has_comments = checker.comment_ranges().intersects(call.arguments.range()); - if !has_comments { + if !has_comments && !unsafe_conversion { if contents.is_empty() { // Ex) `''.format(self.project)` diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement( diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap index f15311cad0..28d2936361 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap @@ -1370,18 +1370,32 @@ help: Convert to f-string 276 + string = f"{(number := number + 1)}" 277 | print(string) 278 | -279 | # Unicode escape +279 | # Unicode escape in regular string, should convert. UP032 [*] Use f-string instead of `format` call --> UP032_0.py:280:1 | -279 | # Unicode escape +279 | # Unicode escape in regular string, should convert. 280 | "\N{angle}AOB = {angle}°".format(angle=180) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +281 | +282 | # Unicode escape in raw string, should not convert - would change semantics. | help: Convert to f-string 277 | print(string) 278 | -279 | # Unicode escape +279 | # Unicode escape in regular string, should convert. - "\N{angle}AOB = {angle}°".format(angle=180) 280 + f"\N{angle}AOB = {180}°" +281 | +282 | # Unicode escape in raw string, should not convert - would change semantics. +283 | r"\N{angle}AOB = {angle}°".format(angle=180) + +UP032 Use f-string instead of `format` call + --> UP032_0.py:283:1 + | +282 | # Unicode escape in raw string, should not convert - would change semantics. +283 | r"\N{angle}AOB = {angle}°".format(angle=180) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Convert to f-string