From d0aebaa25367b480c598b56fbd5a2b47bd97aa6f Mon Sep 17 00:00:00 2001 From: Takayuki Maeda Date: Wed, 29 Oct 2025 04:14:58 +0900 Subject: [PATCH] [`ISC001`] fix panic when string literals are unclosed (#21034) Co-authored-by: Micha Reiser --- .../ISC_syntax_error_2.py | 7 + .../rules/flake8_implicit_str_concat/mod.rs | 8 ++ .../rules/implicit.rs | 67 +++++---- ...__tests__ISC001_ISC_syntax_error_2.py.snap | 134 ++++++++++++++++++ ...__tests__ISC002_ISC_syntax_error_2.py.snap | 53 +++++++ 5 files changed, 244 insertions(+), 25 deletions(-) create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_implicit_str_concat/ISC_syntax_error_2.py create mode 100644 crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC001_ISC_syntax_error_2.py.snap create mode 100644 crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC002_ISC_syntax_error_2.py.snap diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_implicit_str_concat/ISC_syntax_error_2.py b/crates/ruff_linter/resources/test/fixtures/flake8_implicit_str_concat/ISC_syntax_error_2.py new file mode 100644 index 0000000000..0f1264571d --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_implicit_str_concat/ISC_syntax_error_2.py @@ -0,0 +1,7 @@ +# Regression test for https://github.com/astral-sh/ruff/issues/21023 +'' ' +"" "" +'' '' ' +"" "" " +f"" f" +f"" f"" f" diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs index 2b906f3027..f02a049c5a 100644 --- a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs @@ -23,6 +23,14 @@ mod tests { Rule::MultiLineImplicitStringConcatenation, Path::new("ISC_syntax_error.py") )] + #[test_case( + Rule::SingleLineImplicitStringConcatenation, + Path::new("ISC_syntax_error_2.py") + )] + #[test_case( + Rule::MultiLineImplicitStringConcatenation, + Path::new("ISC_syntax_error_2.py") + )] #[test_case(Rule::ExplicitStringConcatenation, Path::new("ISC.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/implicit.rs b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/implicit.rs index c9cc873667..c33dcccabc 100644 --- a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/implicit.rs +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/implicit.rs @@ -1,13 +1,12 @@ use std::borrow::Cow; use itertools::Itertools; - use ruff_macros::{ViolationMetadata, derive_message_formats}; -use ruff_python_ast::str::{leading_quote, trailing_quote}; +use ruff_python_ast::StringFlags; use ruff_python_index::Indexer; -use ruff_python_parser::{TokenKind, Tokens}; +use ruff_python_parser::{Token, TokenKind, Tokens}; use ruff_source_file::LineRanges; -use ruff_text_size::{Ranged, TextRange}; +use ruff_text_size::{Ranged, TextLen, TextRange}; use crate::Locator; use crate::checkers::ast::LintContext; @@ -169,7 +168,8 @@ pub(crate) fn implicit( SingleLineImplicitStringConcatenation, TextRange::new(a_range.start(), b_range.end()), ) { - if let Some(fix) = concatenate_strings(a_range, b_range, locator) { + if let Some(fix) = concatenate_strings(a_token, b_token, a_range, b_range, locator) + { diagnostic.set_fix(fix); } } @@ -177,38 +177,55 @@ pub(crate) fn implicit( } } -fn concatenate_strings(a_range: TextRange, b_range: TextRange, locator: &Locator) -> Option { - let a_text = locator.slice(a_range); - let b_text = locator.slice(b_range); - - let a_leading_quote = leading_quote(a_text)?; - let b_leading_quote = leading_quote(b_text)?; - - // Require, for now, that the leading quotes are the same. - if a_leading_quote != b_leading_quote { +/// Concatenates two strings +/// +/// The `a_string_range` and `b_string_range` are the range of the entire string, +/// not just of the string token itself (important for interpolated strings where +/// the start token doesn't span the entire token). +fn concatenate_strings( + a_token: &Token, + b_token: &Token, + a_string_range: TextRange, + b_string_range: TextRange, + locator: &Locator, +) -> Option { + if a_token.string_flags()?.is_unclosed() || b_token.string_flags()?.is_unclosed() { return None; } - let a_trailing_quote = trailing_quote(a_text)?; - let b_trailing_quote = trailing_quote(b_text)?; + let a_string_flags = a_token.string_flags()?; + let b_string_flags = b_token.string_flags()?; - // Require, for now, that the trailing quotes are the same. - if a_trailing_quote != b_trailing_quote { + let a_prefix = a_string_flags.prefix(); + let b_prefix = b_string_flags.prefix(); + + // Require, for now, that the strings have the same prefix, + // quote style, and number of quotes + if a_prefix != b_prefix + || a_string_flags.quote_style() != b_string_flags.quote_style() + || a_string_flags.is_triple_quoted() != b_string_flags.is_triple_quoted() + { return None; } + let a_text = locator.slice(a_string_range); + let b_text = locator.slice(b_string_range); + + let quotes = a_string_flags.quote_str(); + + let opener_len = a_string_flags.opener_len(); + let closer_len = a_string_flags.closer_len(); + let mut a_body = - Cow::Borrowed(&a_text[a_leading_quote.len()..a_text.len() - a_trailing_quote.len()]); - let b_body = &b_text[b_leading_quote.len()..b_text.len() - b_trailing_quote.len()]; + Cow::Borrowed(&a_text[TextRange::new(opener_len, a_text.text_len() - closer_len)]); + let b_body = &b_text[TextRange::new(opener_len, b_text.text_len() - closer_len)]; - if a_leading_quote.find(['r', 'R']).is_none() - && matches!(b_body.bytes().next(), Some(b'0'..=b'7')) - { + if !a_string_flags.is_raw_string() && matches!(b_body.bytes().next(), Some(b'0'..=b'7')) { normalize_ending_octal(&mut a_body); } - let concatenation = format!("{a_leading_quote}{a_body}{b_body}{a_trailing_quote}"); - let range = TextRange::new(a_range.start(), b_range.end()); + let concatenation = format!("{a_prefix}{quotes}{a_body}{b_body}{quotes}"); + let range = TextRange::new(a_string_range.start(), b_string_range.end()); Some(Fix::safe_edit(Edit::range_replacement( concatenation, diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC001_ISC_syntax_error_2.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC001_ISC_syntax_error_2.py.snap new file mode 100644 index 0000000000..6cd8232919 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC001_ISC_syntax_error_2.py.snap @@ -0,0 +1,134 @@ +--- +source: crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs +--- +ISC001 Implicitly concatenated string literals on one line + --> ISC_syntax_error_2.py:2:1 + | +1 | # Regression test for https://github.com/astral-sh/ruff/issues/21023 +2 | '' ' + | ^^^^ +3 | "" "" +4 | '' '' ' + | +help: Combine string literals + +invalid-syntax: missing closing quote in string literal + --> ISC_syntax_error_2.py:2:4 + | +1 | # Regression test for https://github.com/astral-sh/ruff/issues/21023 +2 | '' ' + | ^ +3 | "" "" +4 | '' '' ' + | + +ISC001 Implicitly concatenated string literals on one line + --> ISC_syntax_error_2.py:3:1 + | +1 | # Regression test for https://github.com/astral-sh/ruff/issues/21023 +2 | '' ' +3 | "" "" + | ^^^^^ +4 | '' '' ' +5 | "" "" " + | +help: Combine string literals + +ISC001 Implicitly concatenated string literals on one line + --> ISC_syntax_error_2.py:4:1 + | +2 | '' ' +3 | "" "" +4 | '' '' ' + | ^^^^^ +5 | "" "" " +6 | f"" f" + | +help: Combine string literals + +ISC001 Implicitly concatenated string literals on one line + --> ISC_syntax_error_2.py:4:4 + | +2 | '' ' +3 | "" "" +4 | '' '' ' + | ^^^^ +5 | "" "" " +6 | f"" f" + | +help: Combine string literals + +invalid-syntax: missing closing quote in string literal + --> ISC_syntax_error_2.py:4:7 + | +2 | '' ' +3 | "" "" +4 | '' '' ' + | ^ +5 | "" "" " +6 | f"" f" + | + +ISC001 Implicitly concatenated string literals on one line + --> ISC_syntax_error_2.py:5:1 + | +3 | "" "" +4 | '' '' ' +5 | "" "" " + | ^^^^^ +6 | f"" f" +7 | f"" f"" f" + | +help: Combine string literals + +ISC001 Implicitly concatenated string literals on one line + --> ISC_syntax_error_2.py:5:4 + | +3 | "" "" +4 | '' '' ' +5 | "" "" " + | ^^^^ +6 | f"" f" +7 | f"" f"" f" + | +help: Combine string literals + +invalid-syntax: missing closing quote in string literal + --> ISC_syntax_error_2.py:5:7 + | +3 | "" "" +4 | '' '' ' +5 | "" "" " + | ^ +6 | f"" f" +7 | f"" f"" f" + | + +invalid-syntax: f-string: unterminated string + --> ISC_syntax_error_2.py:6:7 + | +4 | '' '' ' +5 | "" "" " +6 | f"" f" + | ^ +7 | f"" f"" f" + | + +ISC001 Implicitly concatenated string literals on one line + --> ISC_syntax_error_2.py:7:1 + | +5 | "" "" " +6 | f"" f" +7 | f"" f"" f" + | ^^^^^^^ + | +help: Combine string literals + +invalid-syntax: f-string: unterminated string + --> ISC_syntax_error_2.py:7:11 + | +5 | "" "" " +6 | f"" f" +7 | f"" f"" f" + | ^ + | diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC002_ISC_syntax_error_2.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC002_ISC_syntax_error_2.py.snap new file mode 100644 index 0000000000..3f47730b50 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC002_ISC_syntax_error_2.py.snap @@ -0,0 +1,53 @@ +--- +source: crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs +--- +invalid-syntax: missing closing quote in string literal + --> ISC_syntax_error_2.py:2:4 + | +1 | # Regression test for https://github.com/astral-sh/ruff/issues/21023 +2 | '' ' + | ^ +3 | "" "" +4 | '' '' ' + | + +invalid-syntax: missing closing quote in string literal + --> ISC_syntax_error_2.py:4:7 + | +2 | '' ' +3 | "" "" +4 | '' '' ' + | ^ +5 | "" "" " +6 | f"" f" + | + +invalid-syntax: missing closing quote in string literal + --> ISC_syntax_error_2.py:5:7 + | +3 | "" "" +4 | '' '' ' +5 | "" "" " + | ^ +6 | f"" f" +7 | f"" f"" f" + | + +invalid-syntax: f-string: unterminated string + --> ISC_syntax_error_2.py:6:7 + | +4 | '' '' ' +5 | "" "" " +6 | f"" f" + | ^ +7 | f"" f"" f" + | + +invalid-syntax: f-string: unterminated string + --> ISC_syntax_error_2.py:7:11 + | +5 | "" "" " +6 | f"" f" +7 | f"" f"" f" + | ^ + |