From 7cd6e036e925eff94d841908e1d701ce30642124 Mon Sep 17 00:00:00 2001 From: Dan Date: Sun, 26 Oct 2025 20:55:02 -0400 Subject: [PATCH 1/3] fix-21082 --- .../resources/test/fixtures/flynt/FLY002.py | 16 ++++ .../flynt/rules/static_join_to_fstring.rs | 23 ++++- ...rules__flynt__tests__FLY002_FLY002.py.snap | 96 +++++++++++++++---- 3 files changed, 110 insertions(+), 25 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py b/crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py index 25d3c65ae8..965ed165ee 100644 --- a/crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py +++ b/crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py @@ -29,3 +29,19 @@ nok10 = "".join((foo, '"')) nok11 = ''.join((foo, "'")) nok12 = ''.join([foo, "'", '"']) nok13 = "".join([foo, "'", '"']) + +# Regression test for: https://github.com/astral-sh/ruff/issues/21082 +# Mixing raw and non-raw strings can cause syntax errors or behavior changes +nok14 = "".join((r"", '"')) # First is raw, second is not - would break syntax +nok15 = "".join((r"", "\\")) # First is raw, second has backslash - would break syntax +nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes +nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior +nok18 = "".join((r"", "\\r")) # First is raw, second has carriage return escape - would change behavior + +# Test that all-raw strings still work (should be OK) +ok7 = "".join((r"", r"something")) # Both are raw - OK +ok8 = "\n".join((r"line1", r'line2')) # Both are raw - OK + +# Test that all-non-raw strings still work (should be OK) +ok9 = "".join(("", '"')) # Both are non-raw - OK +ok10 = "\n".join(("line1", "line2")) # Both are non-raw - OK diff --git a/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs b/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs index bdc7340974..ddc73d5bcb 100644 --- a/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs +++ b/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs @@ -74,14 +74,29 @@ fn build_fstring(joiner: &str, joinees: &[Expr], flags: FStringFlags) -> Option< // If all elements are string constants, join them into a single string. if joinees.iter().all(Expr::is_string_literal_expr) { let mut flags: Option = None; + let mut first_raw: Option = None; + + for expr in joinees { + if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = expr { + let curr_flags = value.first_literal_flags(); + let is_raw = curr_flags.prefix().is_raw(); + + if flags.is_none() { + flags = Some(curr_flags); + first_raw = Some(is_raw); + } else if first_raw != Some(is_raw) { + // If not all strings have the same raw/non-raw status, bail. + // Mixing raw and non-raw strings can cause syntax errors or + // behavior changes when creating the joined string. + return None; + } + } + } + let content = joinees .iter() .filter_map(|expr| { if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = expr { - if flags.is_none() { - // Take the flags from the first Expr - flags = Some(value.first_literal_flags()); - } Some(value.to_str()) } else { None diff --git a/crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__FLY002_FLY002.py.snap b/crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__FLY002_FLY002.py.snap index 81a410947c..8790d266e9 100644 --- a/crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__FLY002_FLY002.py.snap +++ b/crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__FLY002_FLY002.py.snap @@ -125,27 +125,6 @@ help: Replace with `f"{secrets.token_urlsafe()}a{secrets.token_hex()}"` 13 | nok2 = a.join(["1", "2", "3"]) # Not OK (not a static joiner) note: This is an unsafe fix and may change runtime behavior -FLY002 [*] Consider f-string instead of string join - --> FLY002.py:20:8 - | -18 | nok7 = "a".join([f"foo{8}", "bar"]) # Not OK (contains an f-string) -19 | # https://github.com/astral-sh/ruff/issues/19887 -20 | nok8 = '\n'.join([r'line1','line2']) - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -21 | nok9 = '\n'.join([r"raw string", '<""">', "<'''>"]) # Not OK (both triple-quote delimiters appear; should bail) - | -help: Replace with f-string -17 | nok6 = "a".join(x for x in "feefoofum") # Not OK (generator) -18 | nok7 = "a".join([f"foo{8}", "bar"]) # Not OK (contains an f-string) -19 | # https://github.com/astral-sh/ruff/issues/19887 - - nok8 = '\n'.join([r'line1','line2']) -20 + nok8 = r'''line1 -21 + line2''' -22 | nok9 = '\n'.join([r"raw string", '<""">', "<'''>"]) # Not OK (both triple-quote delimiters appear; should bail) -23 | -24 | # Regression test for: https://github.com/astral-sh/ruff/issues/7197 -note: This is an unsafe fix and may change runtime behavior - FLY002 [*] Consider `f"{url}{filename}"` instead of string join --> FLY002.py:25:11 | @@ -205,4 +184,79 @@ help: Replace with `f"{foo}'"` 29 + nok11 = f"{foo}'" 30 | nok12 = ''.join([foo, "'", '"']) 31 | nok13 = "".join([foo, "'", '"']) +32 | +note: This is an unsafe fix and may change runtime behavior + +FLY002 [*] Consider `r"something"` instead of string join + --> FLY002.py:42:7 + | +41 | # Test that all-raw strings still work (should be OK) +42 | ok7 = "".join((r"", r"something")) # Both are raw - OK + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +43 | ok8 = "\n".join((r"line1", r'line2')) # Both are raw - OK + | +help: Replace with `r"something"` +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has carriage return escape - would change behavior +40 | +41 | # Test that all-raw strings still work (should be OK) + - ok7 = "".join((r"", r"something")) # Both are raw - OK +42 + ok7 = r"something" # Both are raw - OK +43 | ok8 = "\n".join((r"line1", r'line2')) # Both are raw - OK +44 | +45 | # Test that all-non-raw strings still work (should be OK) +note: This is an unsafe fix and may change runtime behavior + +FLY002 [*] Consider f-string instead of string join + --> FLY002.py:43:7 + | +41 | # Test that all-raw strings still work (should be OK) +42 | ok7 = "".join((r"", r"something")) # Both are raw - OK +43 | ok8 = "\n".join((r"line1", r'line2')) # Both are raw - OK + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +44 | +45 | # Test that all-non-raw strings still work (should be OK) + | +help: Replace with f-string +40 | +41 | # Test that all-raw strings still work (should be OK) +42 | ok7 = "".join((r"", r"something")) # Both are raw - OK + - ok8 = "\n".join((r"line1", r'line2')) # Both are raw - OK +43 + ok8 = r"""line1 +44 + line2""" # Both are raw - OK +45 | +46 | # Test that all-non-raw strings still work (should be OK) +47 | ok9 = "".join(("", '"')) # Both are non-raw - OK +note: This is an unsafe fix and may change runtime behavior + +FLY002 [*] Consider `'"'` instead of string join + --> FLY002.py:46:7 + | +45 | # Test that all-non-raw strings still work (should be OK) +46 | ok9 = "".join(("", '"')) # Both are non-raw - OK + | ^^^^^^^^^^^^^^^^^^ +47 | ok10 = "\n".join(("line1", "line2")) # Both are non-raw - OK + | +help: Replace with `'"'` +43 | ok8 = "\n".join((r"line1", r'line2')) # Both are raw - OK +44 | +45 | # Test that all-non-raw strings still work (should be OK) + - ok9 = "".join(("", '"')) # Both are non-raw - OK +46 + ok9 = '"' # Both are non-raw - OK +47 | ok10 = "\n".join(("line1", "line2")) # Both are non-raw - OK +note: This is an unsafe fix and may change runtime behavior + +FLY002 [*] Consider `"line1\nline2"` instead of string join + --> FLY002.py:47:8 + | +45 | # Test that all-non-raw strings still work (should be OK) +46 | ok9 = "".join(("", '"')) # Both are non-raw - OK +47 | ok10 = "\n".join(("line1", "line2")) # Both are non-raw - OK + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Replace with `"line1\nline2"` +44 | +45 | # Test that all-non-raw strings still work (should be OK) +46 | ok9 = "".join(("", '"')) # Both are non-raw - OK + - ok10 = "\n".join(("line1", "line2")) # Both are non-raw - OK +47 + ok10 = "line1\nline2" # Both are non-raw - OK note: This is an unsafe fix and may change runtime behavior From d371dde2bfa46196f0f0032540c7eea7ea8ae769 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 27 Oct 2025 18:32:15 -0400 Subject: [PATCH 2/3] Improve handling of mixed raw/non-raw string joins in Flynt rule Refines the static_join_to_fstring Flynt rule to better handle cases where raw and non-raw strings are joined, avoiding unsafe raw string representations when content could break syntax or change behavior. Updates test fixtures and snapshots to reflect improved diagnostics and suggestions for problematic joins. --- .../resources/test/fixtures/flynt/FLY002.py | 2 +- .../flynt/rules/static_join_to_fstring.rs | 30 +++- ...rules__flynt__tests__FLY002_FLY002.py.snap | 160 +++++++++++++++++- 3 files changed, 175 insertions(+), 17 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py b/crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py index 965ed165ee..0496cd82af 100644 --- a/crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py +++ b/crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py @@ -36,7 +36,7 @@ nok14 = "".join((r"", '"')) # First is raw, second is not - would break syntax nok15 = "".join((r"", "\\")) # First is raw, second has backslash - would break syntax nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior -nok18 = "".join((r"", "\\r")) # First is raw, second has carriage return escape - would change behavior +nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r # Test that all-raw strings still work (should be OK) ok7 = "".join((r"", r"something")) # Both are raw - OK diff --git a/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs b/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs index ddc73d5bcb..d98d124740 100644 --- a/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs +++ b/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs @@ -2,7 +2,9 @@ use ast::FStringFlags; use itertools::Itertools; use ruff_macros::{ViolationMetadata, derive_message_formats}; -use ruff_python_ast::{self as ast, Arguments, Expr, StringFlags, str::Quote}; +use ruff_python_ast::{ + self as ast, Arguments, Expr, StringFlags, str::Quote, str_prefix::StringLiteralPrefix, +}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; @@ -74,7 +76,7 @@ fn build_fstring(joiner: &str, joinees: &[Expr], flags: FStringFlags) -> Option< // If all elements are string constants, join them into a single string. if joinees.iter().all(Expr::is_string_literal_expr) { let mut flags: Option = None; - let mut first_raw: Option = None; + let mut any_raw = false; for expr in joinees { if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = expr { @@ -83,12 +85,9 @@ fn build_fstring(joiner: &str, joinees: &[Expr], flags: FStringFlags) -> Option< if flags.is_none() { flags = Some(curr_flags); - first_raw = Some(is_raw); - } else if first_raw != Some(is_raw) { - // If not all strings have the same raw/non-raw status, bail. - // Mixing raw and non-raw strings can cause syntax errors or - // behavior changes when creating the joined string. - return None; + any_raw = is_raw; + } else if is_raw { + any_raw = true; } } } @@ -106,6 +105,21 @@ fn build_fstring(joiner: &str, joinees: &[Expr], flags: FStringFlags) -> Option< let mut flags = flags?; + // If any input was raw but the content cannot be safely represented as a raw string, + // use non-raw representation. This handles cases where raw strings would create invalid + // syntax or behavior changes. + if any_raw && !content.is_empty() { + let needs_non_raw = content.contains(['\n', '\r', '\0']) || { + // Check if content contains characters that would break raw string syntax + let quote_char = flags.quote_str(); + content.contains(quote_char) || content.contains('\\') + }; + + if needs_non_raw { + flags = flags.with_prefix(StringLiteralPrefix::Empty); + } + } + // If the result is a raw string and contains a newline, use triple quotes. if flags.prefix().is_raw() && content.contains(['\n', '\r']) { flags = flags.with_triple_quotes(ruff_python_ast::str::TripleQuotes::Yes); diff --git a/crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__FLY002_FLY002.py.snap b/crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__FLY002_FLY002.py.snap index 8790d266e9..7491c77f6c 100644 --- a/crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__FLY002_FLY002.py.snap +++ b/crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__FLY002_FLY002.py.snap @@ -125,6 +125,47 @@ help: Replace with `f"{secrets.token_urlsafe()}a{secrets.token_hex()}"` 13 | nok2 = a.join(["1", "2", "3"]) # Not OK (not a static joiner) note: This is an unsafe fix and may change runtime behavior +FLY002 [*] Consider `'line1\nline2'` instead of string join + --> FLY002.py:20:8 + | +18 | nok7 = "a".join([f"foo{8}", "bar"]) # Not OK (contains an f-string) +19 | # https://github.com/astral-sh/ruff/issues/19887 +20 | nok8 = '\n'.join([r'line1','line2']) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +21 | nok9 = '\n'.join([r"raw string", '<""">', "<'''>"]) # Not OK (both triple-quote delimiters appear; should bail) + | +help: Replace with `'line1\nline2'` +17 | nok6 = "a".join(x for x in "feefoofum") # Not OK (generator) +18 | nok7 = "a".join([f"foo{8}", "bar"]) # Not OK (contains an f-string) +19 | # https://github.com/astral-sh/ruff/issues/19887 + - nok8 = '\n'.join([r'line1','line2']) +20 + nok8 = 'line1\nline2' +21 | nok9 = '\n'.join([r"raw string", '<""">', "<'''>"]) # Not OK (both triple-quote delimiters appear; should bail) +22 | +23 | # Regression test for: https://github.com/astral-sh/ruff/issues/7197 +note: This is an unsafe fix and may change runtime behavior + +FLY002 [*] Consider `"raw string\n<\"\"\">\n<'''>"` instead of string join + --> FLY002.py:21:8 + | +19 | # https://github.com/astral-sh/ruff/issues/19887 +20 | nok8 = '\n'.join([r'line1','line2']) +21 | nok9 = '\n'.join([r"raw string", '<""">', "<'''>"]) # Not OK (both triple-quote delimiters appear; should bail) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +22 | +23 | # Regression test for: https://github.com/astral-sh/ruff/issues/7197 + | +help: Replace with `"raw string\n<\"\"\">\n<'''>"` +18 | nok7 = "a".join([f"foo{8}", "bar"]) # Not OK (contains an f-string) +19 | # https://github.com/astral-sh/ruff/issues/19887 +20 | nok8 = '\n'.join([r'line1','line2']) + - nok9 = '\n'.join([r"raw string", '<""">', "<'''>"]) # Not OK (both triple-quote delimiters appear; should bail) +21 + nok9 = "raw string\n<\"\"\">\n<'''>" # Not OK (both triple-quote delimiters appear; should bail) +22 | +23 | # Regression test for: https://github.com/astral-sh/ruff/issues/7197 +24 | def create_file_public_url(url, filename): +note: This is an unsafe fix and may change runtime behavior + FLY002 [*] Consider `f"{url}{filename}"` instead of string join --> FLY002.py:25:11 | @@ -187,6 +228,110 @@ help: Replace with `f"{foo}'"` 32 | note: This is an unsafe fix and may change runtime behavior +FLY002 [*] Consider `'"'` instead of string join + --> FLY002.py:35:9 + | +33 | # Regression test for: https://github.com/astral-sh/ruff/issues/21082 +34 | # Mixing raw and non-raw strings can cause syntax errors or behavior changes +35 | nok14 = "".join((r"", '"')) # First is raw, second is not - would break syntax + | ^^^^^^^^^^^^^^^^^^^ +36 | nok15 = "".join((r"", "\\")) # First is raw, second has backslash - would break syntax +37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes + | +help: Replace with `'"'` +32 | +33 | # Regression test for: https://github.com/astral-sh/ruff/issues/21082 +34 | # Mixing raw and non-raw strings can cause syntax errors or behavior changes + - nok14 = "".join((r"", '"')) # First is raw, second is not - would break syntax +35 + nok14 = '"' # First is raw, second is not - would break syntax +36 | nok15 = "".join((r"", "\\")) # First is raw, second has backslash - would break syntax +37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes +38 | nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior +note: This is an unsafe fix and may change runtime behavior + +FLY002 [*] Consider `"\\"` instead of string join + --> FLY002.py:36:9 + | +34 | # Mixing raw and non-raw strings can cause syntax errors or behavior changes +35 | nok14 = "".join((r"", '"')) # First is raw, second is not - would break syntax +36 | nok15 = "".join((r"", "\\")) # First is raw, second has backslash - would break syntax + | ^^^^^^^^^^^^^^^^^^^^ +37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes +38 | nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior + | +help: Replace with `"\\"` +33 | # Regression test for: https://github.com/astral-sh/ruff/issues/21082 +34 | # Mixing raw and non-raw strings can cause syntax errors or behavior changes +35 | nok14 = "".join((r"", '"')) # First is raw, second is not - would break syntax + - nok15 = "".join((r"", "\\")) # First is raw, second has backslash - would break syntax +36 + nok15 = "\\" # First is raw, second has backslash - would break syntax +37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes +38 | nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r +note: This is an unsafe fix and may change runtime behavior + +FLY002 [*] Consider `"\x00"` instead of string join + --> FLY002.py:37:9 + | +35 | nok14 = "".join((r"", '"')) # First is raw, second is not - would break syntax +36 | nok15 = "".join((r"", "\\")) # First is raw, second has backslash - would break syntax +37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes + | ^^^^^^^^^^^^^^^^^^^^ +38 | nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r + | +help: Replace with `"\x00"` +34 | # Mixing raw and non-raw strings can cause syntax errors or behavior changes +35 | nok14 = "".join((r"", '"')) # First is raw, second is not - would break syntax +36 | nok15 = "".join((r"", "\\")) # First is raw, second has backslash - would break syntax + - nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes +37 + nok16 = "\x00" # First is raw, second has null byte - would introduce null bytes +38 | nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r +40 | +note: This is an unsafe fix and may change runtime behavior + +FLY002 [*] Consider `"\r"` instead of string join + --> FLY002.py:38:9 + | +36 | nok15 = "".join((r"", "\\")) # First is raw, second has backslash - would break syntax +37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes +38 | nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior + | ^^^^^^^^^^^^^^^^^^^^ +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r + | +help: Replace with `"\r"` +35 | nok14 = "".join((r"", '"')) # First is raw, second is not - would break syntax +36 | nok15 = "".join((r"", "\\")) # First is raw, second has backslash - would break syntax +37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes + - nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior +38 + nok17 = "\r" # First is raw, second has carriage return - would change behavior +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r +40 | +41 | # Test that all-raw strings still work (should be OK) +note: This is an unsafe fix and may change runtime behavior + +FLY002 [*] Consider `"\\r"` instead of string join + --> FLY002.py:39:9 + | +37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes +38 | nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r + | ^^^^^^^^^^^^^^^^^^^^^ +40 | +41 | # Test that all-raw strings still work (should be OK) + | +help: Replace with `"\\r"` +36 | nok15 = "".join((r"", "\\")) # First is raw, second has backslash - would break syntax +37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes +38 | nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior + - nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r +39 + nok18 = "\\r" # First is raw, second has backslash escape followed by r +40 | +41 | # Test that all-raw strings still work (should be OK) +42 | ok7 = "".join((r"", r"something")) # Both are raw - OK +note: This is an unsafe fix and may change runtime behavior + FLY002 [*] Consider `r"something"` instead of string join --> FLY002.py:42:7 | @@ -196,7 +341,7 @@ FLY002 [*] Consider `r"something"` instead of string join 43 | ok8 = "\n".join((r"line1", r'line2')) # Both are raw - OK | help: Replace with `r"something"` -39 | nok18 = "".join((r"", "\\r")) # First is raw, second has carriage return escape - would change behavior +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r 40 | 41 | # Test that all-raw strings still work (should be OK) - ok7 = "".join((r"", r"something")) # Both are raw - OK @@ -206,7 +351,7 @@ help: Replace with `r"something"` 45 | # Test that all-non-raw strings still work (should be OK) note: This is an unsafe fix and may change runtime behavior -FLY002 [*] Consider f-string instead of string join +FLY002 [*] Consider `"line1\nline2"` instead of string join --> FLY002.py:43:7 | 41 | # Test that all-raw strings still work (should be OK) @@ -216,16 +361,15 @@ FLY002 [*] Consider f-string instead of string join 44 | 45 | # Test that all-non-raw strings still work (should be OK) | -help: Replace with f-string +help: Replace with `"line1\nline2"` 40 | 41 | # Test that all-raw strings still work (should be OK) 42 | ok7 = "".join((r"", r"something")) # Both are raw - OK - ok8 = "\n".join((r"line1", r'line2')) # Both are raw - OK -43 + ok8 = r"""line1 -44 + line2""" # Both are raw - OK -45 | -46 | # Test that all-non-raw strings still work (should be OK) -47 | ok9 = "".join(("", '"')) # Both are non-raw - OK +43 + ok8 = "line1\nline2" # Both are raw - OK +44 | +45 | # Test that all-non-raw strings still work (should be OK) +46 | ok9 = "".join(("", '"')) # Both are non-raw - OK note: This is an unsafe fix and may change runtime behavior FLY002 [*] Consider `'"'` instead of string join From b6caf8c92bc64bb1e631a0728e1089740efb434a Mon Sep 17 00:00:00 2001 From: Dan Date: Tue, 28 Oct 2025 21:00:19 -0400 Subject: [PATCH 3/3] Improve raw string handling in static_join_to_fstring Refined logic to better detect when non-raw representation is needed, especially for cases involving backslashes and quote delimiters. Updated test fixtures and snapshots to clarify behavior and expected fixes for edge cases mixing raw and non-raw strings. --- .../resources/test/fixtures/flynt/FLY002.py | 2 +- .../flynt/rules/static_join_to_fstring.rs | 8 +++- ...rules__flynt__tests__FLY002_FLY002.py.snap | 48 ++++++++++--------- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py b/crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py index 0496cd82af..9b4bc0519c 100644 --- a/crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py +++ b/crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py @@ -36,7 +36,7 @@ nok14 = "".join((r"", '"')) # First is raw, second is not - would break syntax nok15 = "".join((r"", "\\")) # First is raw, second has backslash - would break syntax nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior -nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r +nok18 = "".join((r"", "\\r")) # First is raw, second has backslash followed by literal r - OK (no special handling needed) # Test that all-raw strings still work (should be OK) ok7 = "".join((r"", r"something")) # Both are raw - OK diff --git a/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs b/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs index d98d124740..3eaec03aa2 100644 --- a/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs +++ b/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs @@ -109,10 +109,14 @@ fn build_fstring(joiner: &str, joinees: &[Expr], flags: FStringFlags) -> Option< // use non-raw representation. This handles cases where raw strings would create invalid // syntax or behavior changes. if any_raw && !content.is_empty() { - let needs_non_raw = content.contains(['\n', '\r', '\0']) || { + let needs_non_raw = content.contains(['\r', '\0']) || { // Check if content contains characters that would break raw string syntax let quote_char = flags.quote_str(); - content.contains(quote_char) || content.contains('\\') + // A raw string cannot end with a single backslash if it's immediately + // followed by the quote delimiter, as that would be invalid syntax. + let ends_with_backslash = content.ends_with('\\') + && (content.len() == 1 || content.chars().nth_back(1) != Some('\\')); + content.contains(quote_char) || ends_with_backslash }; if needs_non_raw { diff --git a/crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__FLY002_FLY002.py.snap b/crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__FLY002_FLY002.py.snap index 7491c77f6c..f6934590b9 100644 --- a/crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__FLY002_FLY002.py.snap +++ b/crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__FLY002_FLY002.py.snap @@ -125,7 +125,7 @@ help: Replace with `f"{secrets.token_urlsafe()}a{secrets.token_hex()}"` 13 | nok2 = a.join(["1", "2", "3"]) # Not OK (not a static joiner) note: This is an unsafe fix and may change runtime behavior -FLY002 [*] Consider `'line1\nline2'` instead of string join +FLY002 [*] Consider f-string instead of string join --> FLY002.py:20:8 | 18 | nok7 = "a".join([f"foo{8}", "bar"]) # Not OK (contains an f-string) @@ -134,15 +134,16 @@ FLY002 [*] Consider `'line1\nline2'` instead of string join | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 21 | nok9 = '\n'.join([r"raw string", '<""">', "<'''>"]) # Not OK (both triple-quote delimiters appear; should bail) | -help: Replace with `'line1\nline2'` +help: Replace with f-string 17 | nok6 = "a".join(x for x in "feefoofum") # Not OK (generator) 18 | nok7 = "a".join([f"foo{8}", "bar"]) # Not OK (contains an f-string) 19 | # https://github.com/astral-sh/ruff/issues/19887 - nok8 = '\n'.join([r'line1','line2']) -20 + nok8 = 'line1\nline2' -21 | nok9 = '\n'.join([r"raw string", '<""">', "<'''>"]) # Not OK (both triple-quote delimiters appear; should bail) -22 | -23 | # Regression test for: https://github.com/astral-sh/ruff/issues/7197 +20 + nok8 = r'''line1 +21 + line2''' +22 | nok9 = '\n'.join([r"raw string", '<""">', "<'''>"]) # Not OK (both triple-quote delimiters appear; should bail) +23 | +24 | # Regression test for: https://github.com/astral-sh/ruff/issues/7197 note: This is an unsafe fix and may change runtime behavior FLY002 [*] Consider `"raw string\n<\"\"\">\n<'''>"` instead of string join @@ -267,7 +268,7 @@ help: Replace with `"\\"` 36 + nok15 = "\\" # First is raw, second has backslash - would break syntax 37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes 38 | nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior -39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash followed by literal r - OK (no special handling needed) note: This is an unsafe fix and may change runtime behavior FLY002 [*] Consider `"\x00"` instead of string join @@ -278,7 +279,7 @@ FLY002 [*] Consider `"\x00"` instead of string join 37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes | ^^^^^^^^^^^^^^^^^^^^ 38 | nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior -39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash followed by literal r - OK (no special handling needed) | help: Replace with `"\x00"` 34 | # Mixing raw and non-raw strings can cause syntax errors or behavior changes @@ -287,7 +288,7 @@ help: Replace with `"\x00"` - nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes 37 + nok16 = "\x00" # First is raw, second has null byte - would introduce null bytes 38 | nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior -39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash followed by literal r - OK (no special handling needed) 40 | note: This is an unsafe fix and may change runtime behavior @@ -298,7 +299,7 @@ FLY002 [*] Consider `"\r"` instead of string join 37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes 38 | nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior | ^^^^^^^^^^^^^^^^^^^^ -39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash followed by literal r - OK (no special handling needed) | help: Replace with `"\r"` 35 | nok14 = "".join((r"", '"')) # First is raw, second is not - would break syntax @@ -306,27 +307,27 @@ help: Replace with `"\r"` 37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes - nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior 38 + nok17 = "\r" # First is raw, second has carriage return - would change behavior -39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash followed by literal r - OK (no special handling needed) 40 | 41 | # Test that all-raw strings still work (should be OK) note: This is an unsafe fix and may change runtime behavior -FLY002 [*] Consider `"\\r"` instead of string join +FLY002 [*] Consider `r"\r"` instead of string join --> FLY002.py:39:9 | 37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes 38 | nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior -39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash followed by literal r - OK (no special handling needed) | ^^^^^^^^^^^^^^^^^^^^^ 40 | 41 | # Test that all-raw strings still work (should be OK) | -help: Replace with `"\\r"` +help: Replace with `r"\r"` 36 | nok15 = "".join((r"", "\\")) # First is raw, second has backslash - would break syntax 37 | nok16 = "".join((r"", "\0")) # First is raw, second has null byte - would introduce null bytes 38 | nok17 = "".join((r"", "\r")) # First is raw, second has carriage return - would change behavior - - nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r -39 + nok18 = "\\r" # First is raw, second has backslash escape followed by r + - nok18 = "".join((r"", "\\r")) # First is raw, second has backslash followed by literal r - OK (no special handling needed) +39 + nok18 = r"\r" # First is raw, second has backslash followed by literal r - OK (no special handling needed) 40 | 41 | # Test that all-raw strings still work (should be OK) 42 | ok7 = "".join((r"", r"something")) # Both are raw - OK @@ -341,7 +342,7 @@ FLY002 [*] Consider `r"something"` instead of string join 43 | ok8 = "\n".join((r"line1", r'line2')) # Both are raw - OK | help: Replace with `r"something"` -39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash escape followed by r +39 | nok18 = "".join((r"", "\\r")) # First is raw, second has backslash followed by literal r - OK (no special handling needed) 40 | 41 | # Test that all-raw strings still work (should be OK) - ok7 = "".join((r"", r"something")) # Both are raw - OK @@ -351,7 +352,7 @@ help: Replace with `r"something"` 45 | # Test that all-non-raw strings still work (should be OK) note: This is an unsafe fix and may change runtime behavior -FLY002 [*] Consider `"line1\nline2"` instead of string join +FLY002 [*] Consider f-string instead of string join --> FLY002.py:43:7 | 41 | # Test that all-raw strings still work (should be OK) @@ -361,15 +362,16 @@ FLY002 [*] Consider `"line1\nline2"` instead of string join 44 | 45 | # Test that all-non-raw strings still work (should be OK) | -help: Replace with `"line1\nline2"` +help: Replace with f-string 40 | 41 | # Test that all-raw strings still work (should be OK) 42 | ok7 = "".join((r"", r"something")) # Both are raw - OK - ok8 = "\n".join((r"line1", r'line2')) # Both are raw - OK -43 + ok8 = "line1\nline2" # Both are raw - OK -44 | -45 | # Test that all-non-raw strings still work (should be OK) -46 | ok9 = "".join(("", '"')) # Both are non-raw - OK +43 + ok8 = r"""line1 +44 + line2""" # Both are raw - OK +45 | +46 | # Test that all-non-raw strings still work (should be OK) +47 | ok9 = "".join(("", '"')) # Both are non-raw - OK note: This is an unsafe fix and may change runtime behavior FLY002 [*] Consider `'"'` instead of string join