diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF027_0.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF027_0.py index c863347069..04c482bd52 100644 --- a/crates/ruff_linter/resources/test/fixtures/ruff/RUF027_0.py +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF027_0.py @@ -84,3 +84,9 @@ def in_type_def(): # https://github.com/astral-sh/ruff/issues/18860 def fuzz_bug(): c('{\t"i}') + +# Test case for backslash handling in f-string interpolations +# Should not trigger RUF027 for Python < 3.12 due to backslashes in interpolations +def backslash_test(): + x = "test" + print("Hello {'\\n'}{x}") # Should not trigger RUF027 for Python < 3.12 diff --git a/crates/ruff_linter/src/rules/ruff/mod.rs b/crates/ruff_linter/src/rules/ruff/mod.rs index c2d03fb1ae..e516c454cf 100644 --- a/crates/ruff_linter/src/rules/ruff/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/mod.rs @@ -129,6 +129,21 @@ mod tests { Ok(()) } + #[test] + fn missing_fstring_syntax_backslash_py311() -> Result<()> { + let diagnostics = test_path( + Path::new("ruff/RUF027_0.py"), + &LinterSettings { + unresolved_target_version: PythonVersion::PY311.into(), + ..LinterSettings::for_rule(Rule::MissingFStringSyntax) + }, + )?; + // With Python 3.11, backslashes in interpolations should NOT trigger RUF027 + // (only the backslash_test function should be skipped) + assert_diagnostics!(diagnostics); + Ok(()) + } + #[test] fn prefer_parentheses_getitem_tuple() -> Result<()> { let diagnostics = test_path( diff --git a/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs b/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs index 229abff0d7..d5c2993398 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs @@ -2,7 +2,7 @@ use memchr::memchr2_iter; use rustc_hash::FxHashSet; use ruff_macros::{ViolationMetadata, derive_message_formats}; -use ruff_python_ast as ast; +use ruff_python_ast::{self as ast, PythonVersion}; use ruff_python_literal::format::FormatSpec; use ruff_python_parser::parse_expression; use ruff_python_semantic::analyze::logging::is_logger_candidate; @@ -116,7 +116,12 @@ pub(crate) fn missing_fstring_syntax(checker: &Checker, literal: &ast::StringLit return; } - if should_be_fstring(literal, checker.locator(), semantic) { + if should_be_fstring( + literal, + checker.locator(), + semantic, + checker.target_version(), + ) { checker .report_diagnostic(MissingFStringSyntax, literal.range()) .set_fix(fix_fstring_syntax(literal.range())); @@ -180,6 +185,7 @@ fn should_be_fstring( literal: &ast::StringLiteral, locator: &Locator, semantic: &SemanticModel, + target_version: PythonVersion, ) -> bool { if !has_brackets(&literal.value) { return false; @@ -216,6 +222,13 @@ fn should_be_fstring( for f_string in value.f_strings() { let mut has_name = false; for element in f_string.elements.interpolations() { + // Check if the interpolation expression contains backslashes + // F-strings with backslashes in interpolations are only valid in Python 3.12+ + let interpolation_text = &fstring_expr[element.range()]; + if interpolation_text.contains('\\') && target_version < PythonVersion::PY312 { + return false; + } + if let ast::Expr::Name(ast::ExprName { id, .. }) = element.expression.as_ref() { if arg_names.contains(id) { return false; diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF027_RUF027_0.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF027_RUF027_0.py.snap index fcac406153..977628639d 100644 --- a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF027_RUF027_0.py.snap +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF027_RUF027_0.py.snap @@ -320,3 +320,19 @@ help: Add `f` prefix 76 | # fstrings are never correct as type definitions 77 | # so we should always skip those note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:92:11 + | +90 | def backslash_test(): +91 | x = "test" +92 | print("Hello {'\\n'}{x}") # Should not trigger RUF027 for Python < 3.12 + | ^^^^^^^^^^^^^^^^^^ + | +help: Add `f` prefix +89 | # Should not trigger RUF027 for Python < 3.12 due to backslashes in interpolations +90 | def backslash_test(): +91 | x = "test" + - print("Hello {'\\n'}{x}") # Should not trigger RUF027 for Python < 3.12 +92 + print(f"Hello {'\\n'}{x}") # Should not trigger RUF027 for Python < 3.12 +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__missing_fstring_syntax_backslash_py311.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__missing_fstring_syntax_backslash_py311.snap new file mode 100644 index 0000000000..2230e45c9a --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__missing_fstring_syntax_backslash_py311.snap @@ -0,0 +1,300 @@ +--- +source: crates/ruff_linter/src/rules/ruff/mod.rs +--- +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:5:7 + | +3 | "always ignore this: {val}" +4 | +5 | print("but don't ignore this: {val}") # RUF027 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Add `f` prefix +2 | +3 | "always ignore this: {val}" +4 | + - print("but don't ignore this: {val}") # RUF027 +5 + print(f"but don't ignore this: {val}") # RUF027 +6 | +7 | +8 | def simple_cases(): +note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:10:9 + | + 8 | def simple_cases(): + 9 | a = 4 +10 | b = "{a}" # RUF027 + | ^^^^^ +11 | c = "{a} {b} f'{val}' " # RUF027 + | +help: Add `f` prefix +7 | +8 | def simple_cases(): +9 | a = 4 + - b = "{a}" # RUF027 +10 + b = f"{a}" # RUF027 +11 | c = "{a} {b} f'{val}' " # RUF027 +12 | +13 | +note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:11:9 + | + 9 | a = 4 +10 | b = "{a}" # RUF027 +11 | c = "{a} {b} f'{val}' " # RUF027 + | ^^^^^^^^^^^^^^^^^^^ + | +help: Add `f` prefix +8 | def simple_cases(): +9 | a = 4 +10 | b = "{a}" # RUF027 + - c = "{a} {b} f'{val}' " # RUF027 +11 + c = f"{a} {b} f'{val}' " # RUF027 +12 | +13 | +14 | def escaped_string(): +note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:21:9 + | +19 | def raw_string(): +20 | a = 4 +21 | b = r"raw string with formatting: {a}" # RUF027 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +22 | c = r"raw string with \backslashes\ and \"escaped quotes\": {a}" # RUF027 + | +help: Add `f` prefix +18 | +19 | def raw_string(): +20 | a = 4 + - b = r"raw string with formatting: {a}" # RUF027 +21 + b = fr"raw string with formatting: {a}" # RUF027 +22 | c = r"raw string with \backslashes\ and \"escaped quotes\": {a}" # RUF027 +23 | +24 | +note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:22:9 + | +20 | a = 4 +21 | b = r"raw string with formatting: {a}" # RUF027 +22 | c = r"raw string with \backslashes\ and \"escaped quotes\": {a}" # RUF027 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Add `f` prefix +19 | def raw_string(): +20 | a = 4 +21 | b = r"raw string with formatting: {a}" # RUF027 + - c = r"raw string with \backslashes\ and \"escaped quotes\": {a}" # RUF027 +22 + c = fr"raw string with \backslashes\ and \"escaped quotes\": {a}" # RUF027 +23 | +24 | +25 | def print_name(name: str): +note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:27:11 + | +25 | def print_name(name: str): +26 | a = 4 +27 | print("Hello, {name}!") # RUF027 + | ^^^^^^^^^^^^^^^^ +28 | print("The test value we're using today is {a}") # RUF027 + | +help: Add `f` prefix +24 | +25 | def print_name(name: str): +26 | a = 4 + - print("Hello, {name}!") # RUF027 +27 + print(f"Hello, {name}!") # RUF027 +28 | print("The test value we're using today is {a}") # RUF027 +29 | +30 | +note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:28:11 + | +26 | a = 4 +27 | print("Hello, {name}!") # RUF027 +28 | print("The test value we're using today is {a}") # RUF027 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Add `f` prefix +25 | def print_name(name: str): +26 | a = 4 +27 | print("Hello, {name}!") # RUF027 + - print("The test value we're using today is {a}") # RUF027 +28 + print(f"The test value we're using today is {a}") # RUF027 +29 | +30 | +31 | def nested_funcs(): +note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:33:33 + | +31 | def nested_funcs(): +32 | a = 4 +33 | print(do_nothing(do_nothing("{a}"))) # RUF027 + | ^^^^^ + | +help: Add `f` prefix +30 | +31 | def nested_funcs(): +32 | a = 4 + - print(do_nothing(do_nothing("{a}"))) # RUF027 +33 + print(do_nothing(do_nothing(f"{a}"))) # RUF027 +34 | +35 | +36 | def tripled_quoted(): +note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:39:19 + | +37 | a = 4 +38 | c = a +39 | single_line = """ {a} """ # RUF027 + | ^^^^^^^^^^^ +40 | # RUF027 +41 | multi_line = a = """b { # comment + | +help: Add `f` prefix +36 | def tripled_quoted(): +37 | a = 4 +38 | c = a + - single_line = """ {a} """ # RUF027 +39 + single_line = f""" {a} """ # RUF027 +40 | # RUF027 +41 | multi_line = a = """b { # comment +42 | c} d +note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:41:22 + | +39 | single_line = """ {a} """ # RUF027 +40 | # RUF027 +41 | multi_line = a = """b { # comment + | ______________________^ +42 | | c} d +43 | | """ + | |_______^ + | +help: Add `f` prefix +38 | c = a +39 | single_line = """ {a} """ # RUF027 +40 | # RUF027 + - multi_line = a = """b { # comment +41 + multi_line = a = f"""b { # comment +42 | c} d +43 | """ +44 | +note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:56:9 + | +54 | def implicit_concat(): +55 | a = 4 +56 | b = "{a}" "+" "{b}" r" \\ " # RUF027 for the first part only + | ^^^^^ +57 | print(f"{a}" "{a}" f"{b}") # RUF027 + | +help: Add `f` prefix +53 | +54 | def implicit_concat(): +55 | a = 4 + - b = "{a}" "+" "{b}" r" \\ " # RUF027 for the first part only +56 + b = f"{a}" "+" "{b}" r" \\ " # RUF027 for the first part only +57 | print(f"{a}" "{a}" f"{b}") # RUF027 +58 | +59 | +note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:57:18 + | +55 | a = 4 +56 | b = "{a}" "+" "{b}" r" \\ " # RUF027 for the first part only +57 | print(f"{a}" "{a}" f"{b}") # RUF027 + | ^^^^^ + | +help: Add `f` prefix +54 | def implicit_concat(): +55 | a = 4 +56 | b = "{a}" "+" "{b}" r" \\ " # RUF027 for the first part only + - print(f"{a}" "{a}" f"{b}") # RUF027 +57 + print(f"{a}" f"{a}" f"{b}") # RUF027 +58 | +59 | +60 | def escaped_chars(): +note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:62:9 + | +60 | def escaped_chars(): +61 | a = 4 +62 | b = "\"not escaped:\" '{a}' \"escaped:\": '{{c}}'" # RUF027 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Add `f` prefix +59 | +60 | def escaped_chars(): +61 | a = 4 + - b = "\"not escaped:\" '{a}' \"escaped:\": '{{c}}'" # RUF027 +62 + b = f"\"not escaped:\" '{a}' \"escaped:\": '{{c}}'" # RUF027 +63 | +64 | +65 | def method_calls(): +note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:70:18 + | +68 | first = "Wendy" +69 | last = "Appleseed" +70 | value.method("{first} {last}") # RUF027 + | ^^^^^^^^^^^^^^^^ +71 | +72 | def format_specifiers(): + | +help: Add `f` prefix +67 | value.method = print_name +68 | first = "Wendy" +69 | last = "Appleseed" + - value.method("{first} {last}") # RUF027 +70 + value.method(f"{first} {last}") # RUF027 +71 | +72 | def format_specifiers(): +73 | a = 4 +note: This is an unsafe fix and may change runtime behavior + +RUF027 [*] Possible f-string without an `f` prefix + --> RUF027_0.py:74:9 + | +72 | def format_specifiers(): +73 | a = 4 +74 | b = "{a:b} {a:^5}" + | ^^^^^^^^^^^^^^ +75 | +76 | # fstrings are never correct as type definitions + | +help: Add `f` prefix +71 | +72 | def format_specifiers(): +73 | a = 4 + - b = "{a:b} {a:^5}" +74 + b = f"{a:b} {a:^5}" +75 | +76 | # fstrings are never correct as type definitions +77 | # so we should always skip those +note: This is an unsafe fix and may change runtime behavior