diff --git a/crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py b/crates/ruff_linter/resources/test/fixtures/flynt/FLY002.py index 25d3c65ae8..9b4bc0519c 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 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 +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..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 @@ -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,14 +76,26 @@ 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 any_raw = false; + + 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); + any_raw = is_raw; + } else if is_raw { + any_raw = true; + } + } + } + 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 @@ -91,6 +105,25 @@ 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(['\r', '\0']) || { + // Check if content contains characters that would break raw string syntax + let quote_char = flags.quote_str(); + // 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 { + 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 81a410947c..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 @@ -146,6 +146,27 @@ help: Replace with f-string 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 + --> 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 | @@ -205,4 +226,183 @@ 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 `'"'` 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 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 + --> 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 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 +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 followed by literal r - OK (no special handling needed) +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 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 +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 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"\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 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"\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 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 +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 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 +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