[`ISC001`] fix panic when string literals are unclosed (#21034)

Co-authored-by: Micha Reiser <micha@reiser.io>
This commit is contained in:
Takayuki Maeda 2025-10-29 04:14:58 +09:00 committed by GitHub
parent 17850eee4b
commit d0aebaa253
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 244 additions and 25 deletions

View File

@ -0,0 +1,7 @@
# Regression test for https://github.com/astral-sh/ruff/issues/21023
'' '
"" ""
'' '' '
"" "" "
f"" f"
f"" f"" f"

View File

@ -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());

View File

@ -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<Fix> {
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<Fix> {
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,

View File

@ -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"
| ^
|

View File

@ -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"
| ^
|