-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Convert raw strings to non-raw when fixes add escape sequences (#13294) #13882
Changes from all commits
5696f1e
17bd63a
30ae80c
d4051f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -1,3 +1,7 @@ | ||||||||||||||||||
use ruff_python_ast::str::Quote; | ||||||||||||||||||
use ruff_python_ast::StringFlags; | ||||||||||||||||||
use ruff_python_parser::Token; | ||||||||||||||||||
use ruff_text_size::Ranged; | ||||||||||||||||||
use ruff_text_size::{TextLen, TextRange, TextSize}; | ||||||||||||||||||
|
||||||||||||||||||
use ruff_diagnostics::AlwaysFixableViolation; | ||||||||||||||||||
|
@@ -172,19 +176,33 @@ impl AlwaysFixableViolation for InvalidCharacterZeroWidthSpace { | |||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
/// PLE2510, PLE2512, PLE2513, PLE2514, PLE2515 | ||||||||||||||||||
pub(crate) fn invalid_string_characters( | ||||||||||||||||||
pub(crate) fn invalid_string_characters<'a>( | ||||||||||||||||||
diagnostics: &mut Vec<Diagnostic>, | ||||||||||||||||||
token: TokenKind, | ||||||||||||||||||
range: TextRange, | ||||||||||||||||||
token: &'a Token, | ||||||||||||||||||
last_fstring_start: &mut Option<&'a Token>, | ||||||||||||||||||
locator: &Locator, | ||||||||||||||||||
) { | ||||||||||||||||||
let text = match token { | ||||||||||||||||||
struct InvalidCharacterDiagnostic { | ||||||||||||||||||
diagnostic: Diagnostic, | ||||||||||||||||||
edit: Edit, | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
let kind = token.kind(); | ||||||||||||||||||
let range = token.range(); | ||||||||||||||||||
|
||||||||||||||||||
let text = match kind { | ||||||||||||||||||
// We can't use the `value` field since it's decoded and e.g. for f-strings removed a curly | ||||||||||||||||||
// brace that escaped another curly brace, which would gives us wrong column information. | ||||||||||||||||||
TokenKind::String | TokenKind::FStringMiddle => locator.slice(range), | ||||||||||||||||||
TokenKind::FStringStart => { | ||||||||||||||||||
*last_fstring_start = Some(token); | ||||||||||||||||||
return; | ||||||||||||||||||
} | ||||||||||||||||||
_ => return, | ||||||||||||||||||
}; | ||||||||||||||||||
|
||||||||||||||||||
// Accumulate diagnostics here to postpone generating shared fixes until we know we need them. | ||||||||||||||||||
let mut new_diagnostics: Vec<InvalidCharacterDiagnostic> = Vec::new(); | ||||||||||||||||||
for (column, match_) in text.match_indices(&['\x08', '\x1A', '\x1B', '\0', '\u{200b}']) { | ||||||||||||||||||
let c = match_.chars().next().unwrap(); | ||||||||||||||||||
let (replacement, rule): (&str, DiagnosticKind) = match c { | ||||||||||||||||||
|
@@ -201,8 +219,97 @@ pub(crate) fn invalid_string_characters( | |||||||||||||||||
let location = range.start() + TextSize::try_from(column).unwrap(); | ||||||||||||||||||
let range = TextRange::at(location, c.text_len()); | ||||||||||||||||||
|
||||||||||||||||||
diagnostics.push(Diagnostic::new(rule, range).with_fix(Fix::safe_edit( | ||||||||||||||||||
Edit::range_replacement(replacement.to_string(), range), | ||||||||||||||||||
))); | ||||||||||||||||||
new_diagnostics.push(InvalidCharacterDiagnostic { | ||||||||||||||||||
diagnostic: Diagnostic::new(rule, range), | ||||||||||||||||||
// This is integrated with other fixes and attached to the diagnostic below. | ||||||||||||||||||
edit: Edit::range_replacement(replacement.to_string(), range), | ||||||||||||||||||
}); | ||||||||||||||||||
} | ||||||||||||||||||
if new_diagnostics.is_empty() { | ||||||||||||||||||
// No issues, nothing to fix. | ||||||||||||||||||
return; | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// Convert raw strings to non-raw strings when fixes are applied: | ||||||||||||||||||
// https://github.com/astral-sh/ruff/issues/13294#issuecomment-2341955180 | ||||||||||||||||||
let mut string_conversion_edits = Vec::new(); | ||||||||||||||||||
if token.is_raw_string() { | ||||||||||||||||||
let string_flags = token.string_flags(); | ||||||||||||||||||
let prefix = string_flags.prefix().as_str(); | ||||||||||||||||||
|
||||||||||||||||||
// 1. Remove the raw string prefix. | ||||||||||||||||||
for (column, match_) in prefix.match_indices(&['r', 'R']) { | ||||||||||||||||||
let c = match_.chars().next().unwrap(); | ||||||||||||||||||
|
||||||||||||||||||
let entire_string_range = match kind { | ||||||||||||||||||
TokenKind::String => range, | ||||||||||||||||||
_ => last_fstring_start.unwrap().range(), | ||||||||||||||||||
}; | ||||||||||||||||||
let location = entire_string_range.start() + TextSize::try_from(column).unwrap(); | ||||||||||||||||||
let range = TextRange::at(location, c.text_len()); | ||||||||||||||||||
|
||||||||||||||||||
string_conversion_edits.push(Edit::range_deletion(range)); | ||||||||||||||||||
} | ||||||||||||||||||
Comment on lines
+241
to
+252
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: there can always only be at most one |
||||||||||||||||||
|
||||||||||||||||||
// 2. Escape '\' and quote characters inside the string content. | ||||||||||||||||||
let (content_start, content_end): (TextSize, TextSize) = match kind { | ||||||||||||||||||
TokenKind::String => ( | ||||||||||||||||||
prefix.text_len() + string_flags.quote_len(), | ||||||||||||||||||
TextSize::try_from(text.len()).unwrap() - string_flags.quote_len(), | ||||||||||||||||||
), | ||||||||||||||||||
_ => (0.into(), text.len().try_into().unwrap()), | ||||||||||||||||||
}; | ||||||||||||||||||
Comment on lines
+258
to
+261
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can use let string_content = &text[range];
Suggested change
|
||||||||||||||||||
let string_content = &text[content_start.to_usize()..content_end.to_usize()]; | ||||||||||||||||||
for (column, match_) in string_content.match_indices(&['\\', '\'', '"']) { | ||||||||||||||||||
let c = match_.chars().next().unwrap(); | ||||||||||||||||||
let replacement: &str = match c { | ||||||||||||||||||
'\\' => "\\\\", | ||||||||||||||||||
'\'' | '"' => { | ||||||||||||||||||
// Quotes only have to be escaped in triple-quoted strings at the beginning | ||||||||||||||||||
// of a triplet (like `\"""\"""` within the string, or `\""""` at the end). | ||||||||||||||||||
// For simplicity, escape all quotes followed by the same character | ||||||||||||||||||
// (e.g., `r""" \""" \""""` becomes `""" \\\"\"" \""""`). | ||||||||||||||||||
if string_flags.is_triple_quoted() | ||||||||||||||||||
&& string_content | ||||||||||||||||||
.as_bytes() | ||||||||||||||||||
.get(column + 1) | ||||||||||||||||||
.is_some_and(|c2| char::from(*c2) != c) | ||||||||||||||||||
Comment on lines
+273
to
+276
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using bytes to get the next characters panics if the next character is a non-ASCII character. We should use the |
||||||||||||||||||
{ | ||||||||||||||||||
continue; | ||||||||||||||||||
} | ||||||||||||||||||
match (c, string_flags.quote_style()) { | ||||||||||||||||||
('\'', Quote::Single) => "\\'", | ||||||||||||||||||
('"', Quote::Double) => "\\\"", | ||||||||||||||||||
_ => { | ||||||||||||||||||
continue; | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
_ => { | ||||||||||||||||||
continue; | ||||||||||||||||||
} | ||||||||||||||||||
}; | ||||||||||||||||||
|
||||||||||||||||||
let location = range.start() + content_start + TextSize::try_from(column).unwrap(); | ||||||||||||||||||
let range = TextRange::at(location, c.text_len()); | ||||||||||||||||||
|
||||||||||||||||||
string_conversion_edits.push(Edit::range_replacement(replacement.to_string(), range)); | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// 3. Add back '\' characters for line continuation in non-triple-quoted strings. | ||||||||||||||||||
if !string_flags.is_triple_quoted() { | ||||||||||||||||||
for (column, _match) in string_content.match_indices("\\\n") { | ||||||||||||||||||
let location = range.start() + content_start + TextSize::try_from(column).unwrap(); | ||||||||||||||||||
string_conversion_edits.push(Edit::insertion( | ||||||||||||||||||
"\\n\\".to_string(), | ||||||||||||||||||
location + TextSize::from(1), | ||||||||||||||||||
)); | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
for InvalidCharacterDiagnostic { diagnostic, edit } in new_diagnostics { | ||||||||||||||||||
diagnostics | ||||||||||||||||||
.push(diagnostic.with_fix(Fix::safe_edits(edit, string_conversion_edits.clone()))); | ||||||||||||||||||
} | ||||||||||||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We also need to handle the case where the invalid character was the last character before the quotes in a triple quoted strings:
"""test"<invalid>"""
Let's say
<invalid>
is the invalid character. Removing<invalid>
then results in"""test""""
which is not a valid non-raw strings.