mirror of
https://github.com/astral-sh/ruff.git
synced 2025-10-01 22:31:47 +00:00
Move Q003
to AST checker (#10923)
## Summary This PR moves the `Q003` rule to AST checker. This is the final rule that used the docstring detection state machine and thus this PR removes it as well. resolves: #7595 resolves: #7808 ## Test Plan - [x] `cargo test` - [x] Make sure there are no changes in the ecosystem
This commit is contained in:
parent
812b0976a9
commit
f9a828f493
9 changed files with 307 additions and 319 deletions
|
@ -33,4 +33,7 @@ pub(crate) fn string_like(string_like: StringLike, checker: &mut Checker) {
|
||||||
if checker.enabled(Rule::UnnecessaryEscapedQuote) {
|
if checker.enabled(Rule::UnnecessaryEscapedQuote) {
|
||||||
flake8_quotes::rules::unnecessary_escaped_quote(checker, string_like);
|
flake8_quotes::rules::unnecessary_escaped_quote(checker, string_like);
|
||||||
}
|
}
|
||||||
|
if checker.enabled(Rule::AvoidableEscapedQuote) && checker.settings.flake8_quotes.avoid_escape {
|
||||||
|
flake8_quotes::rules::avoidable_escaped_quote(checker, string_like);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ use crate::registry::{AsRule, Rule};
|
||||||
use crate::rules::pycodestyle::rules::BlankLinesChecker;
|
use crate::rules::pycodestyle::rules::BlankLinesChecker;
|
||||||
use crate::rules::{
|
use crate::rules::{
|
||||||
eradicate, flake8_commas, flake8_executable, flake8_fixme, flake8_implicit_str_concat,
|
eradicate, flake8_commas, flake8_executable, flake8_fixme, flake8_implicit_str_concat,
|
||||||
flake8_pyi, flake8_quotes, flake8_todos, pycodestyle, pygrep_hooks, pylint, pyupgrade, ruff,
|
flake8_pyi, flake8_todos, pycodestyle, pygrep_hooks, pylint, pyupgrade, ruff,
|
||||||
};
|
};
|
||||||
use crate::settings::LinterSettings;
|
use crate::settings::LinterSettings;
|
||||||
|
|
||||||
|
@ -122,10 +122,6 @@ pub(crate) fn check_tokens(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.rules.enabled(Rule::AvoidableEscapedQuote) && settings.flake8_quotes.avoid_escape {
|
|
||||||
flake8_quotes::rules::avoidable_escaped_quote(&mut diagnostics, tokens, locator, settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.rules.any_enabled(&[
|
if settings.rules.any_enabled(&[
|
||||||
Rule::SingleLineImplicitStringConcatenation,
|
Rule::SingleLineImplicitStringConcatenation,
|
||||||
Rule::MultiLineImplicitStringConcatenation,
|
Rule::MultiLineImplicitStringConcatenation,
|
||||||
|
|
|
@ -1,122 +0,0 @@
|
||||||
//! Extract docstrings via tokenization.
|
|
||||||
//!
|
|
||||||
//! See: <https://github.com/zheller/flake8-quotes/blob/ef0d9a90249a080e460b70ab62bf4b65e5aa5816/flake8_quotes/docstring_detection.py#L29>
|
|
||||||
//!
|
|
||||||
//! TODO(charlie): Consolidate with the existing AST-based docstring extraction.
|
|
||||||
|
|
||||||
use ruff_python_parser::Tok;
|
|
||||||
|
|
||||||
#[derive(Default, Copy, Clone)]
|
|
||||||
enum State {
|
|
||||||
// Start of the module: first string gets marked as a docstring.
|
|
||||||
#[default]
|
|
||||||
ExpectModuleDocstring,
|
|
||||||
// After seeing a class definition, we're waiting for the block colon (and do bracket
|
|
||||||
// counting).
|
|
||||||
ExpectClassColon,
|
|
||||||
// After seeing the block colon in a class definition, we expect a docstring.
|
|
||||||
ExpectClassDocstring,
|
|
||||||
// Same as ExpectClassColon, but for function definitions.
|
|
||||||
ExpectFunctionColon,
|
|
||||||
// Same as ExpectClassDocstring, but for function definitions.
|
|
||||||
ExpectFunctionDocstring,
|
|
||||||
// Skip tokens until we observe a `class` or `def`.
|
|
||||||
Other,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(crate) struct StateMachine {
|
|
||||||
state: State,
|
|
||||||
bracket_count: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StateMachine {
|
|
||||||
pub(crate) fn consume(&mut self, tok: &Tok) -> bool {
|
|
||||||
match tok {
|
|
||||||
Tok::NonLogicalNewline
|
|
||||||
| Tok::Newline
|
|
||||||
| Tok::Indent
|
|
||||||
| Tok::Dedent
|
|
||||||
| Tok::Comment(..) => false,
|
|
||||||
|
|
||||||
Tok::String { .. } => {
|
|
||||||
if matches!(
|
|
||||||
self.state,
|
|
||||||
State::ExpectModuleDocstring
|
|
||||||
| State::ExpectClassDocstring
|
|
||||||
| State::ExpectFunctionDocstring
|
|
||||||
) {
|
|
||||||
self.state = State::Other;
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Tok::Class => {
|
|
||||||
self.state = State::ExpectClassColon;
|
|
||||||
self.bracket_count = 0;
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
Tok::Def => {
|
|
||||||
self.state = State::ExpectFunctionColon;
|
|
||||||
self.bracket_count = 0;
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
Tok::Colon => {
|
|
||||||
if self.bracket_count == 0 {
|
|
||||||
if matches!(self.state, State::ExpectClassColon) {
|
|
||||||
self.state = State::ExpectClassDocstring;
|
|
||||||
} else if matches!(self.state, State::ExpectFunctionColon) {
|
|
||||||
self.state = State::ExpectFunctionDocstring;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
Tok::Lpar | Tok::Lbrace | Tok::Lsqb => {
|
|
||||||
self.bracket_count = self.bracket_count.saturating_add(1);
|
|
||||||
if matches!(
|
|
||||||
self.state,
|
|
||||||
State::ExpectModuleDocstring
|
|
||||||
| State::ExpectClassDocstring
|
|
||||||
| State::ExpectFunctionDocstring
|
|
||||||
) {
|
|
||||||
self.state = State::Other;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
Tok::Rpar | Tok::Rbrace | Tok::Rsqb => {
|
|
||||||
self.bracket_count = self.bracket_count.saturating_sub(1);
|
|
||||||
if matches!(
|
|
||||||
self.state,
|
|
||||||
State::ExpectModuleDocstring
|
|
||||||
| State::ExpectClassDocstring
|
|
||||||
| State::ExpectFunctionDocstring
|
|
||||||
) {
|
|
||||||
self.state = State::Other;
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => {
|
|
||||||
if matches!(
|
|
||||||
self.state,
|
|
||||||
State::ExpectModuleDocstring
|
|
||||||
| State::ExpectClassDocstring
|
|
||||||
| State::ExpectFunctionDocstring
|
|
||||||
) {
|
|
||||||
self.state = State::Other;
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
pub(crate) mod docstring_detection;
|
|
|
@ -24,7 +24,6 @@ mod docstrings;
|
||||||
mod fix;
|
mod fix;
|
||||||
pub mod fs;
|
pub mod fs;
|
||||||
mod importer;
|
mod importer;
|
||||||
mod lex;
|
|
||||||
pub mod line_width;
|
pub mod line_width;
|
||||||
pub mod linter;
|
pub mod linter;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
|
|
|
@ -256,7 +256,6 @@ impl Rule {
|
||||||
| Rule::MixedSpacesAndTabs
|
| Rule::MixedSpacesAndTabs
|
||||||
| Rule::TrailingWhitespace => LintSource::PhysicalLines,
|
| Rule::TrailingWhitespace => LintSource::PhysicalLines,
|
||||||
Rule::AmbiguousUnicodeCharacterComment
|
Rule::AmbiguousUnicodeCharacterComment
|
||||||
| Rule::AvoidableEscapedQuote
|
|
||||||
| Rule::BlanketNOQA
|
| Rule::BlanketNOQA
|
||||||
| Rule::BlanketTypeIgnore
|
| Rule::BlanketTypeIgnore
|
||||||
| Rule::BlankLineAfterDecorator
|
| Rule::BlankLineAfterDecorator
|
||||||
|
|
|
@ -1,3 +1,12 @@
|
||||||
|
use ruff_python_ast::AnyStringKind;
|
||||||
|
use ruff_text_size::TextLen;
|
||||||
|
|
||||||
|
/// Returns the raw contents of the string given the string's contents and kind.
|
||||||
|
/// This is a string without the prefix and quotes.
|
||||||
|
pub(super) fn raw_contents(contents: &str, kind: AnyStringKind) -> &str {
|
||||||
|
&contents[kind.opener_len().to_usize()..(contents.text_len() - kind.closer_len()).to_usize()]
|
||||||
|
}
|
||||||
|
|
||||||
/// Return `true` if the haystack contains an escaped quote.
|
/// Return `true` if the haystack contains an escaped quote.
|
||||||
pub(super) fn contains_escaped_quote(haystack: &str, quote: char) -> bool {
|
pub(super) fn contains_escaped_quote(haystack: &str, quote: char) -> bool {
|
||||||
for index in memchr::memchr_iter(quote as u8, haystack.as_bytes()) {
|
for index in memchr::memchr_iter(quote as u8, haystack.as_bytes()) {
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
|
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
|
||||||
use ruff_macros::{derive_message_formats, violation};
|
use ruff_macros::{derive_message_formats, violation};
|
||||||
use ruff_python_parser::lexer::LexResult;
|
use ruff_python_ast::visitor::{walk_f_string, Visitor};
|
||||||
use ruff_python_parser::Tok;
|
use ruff_python_ast::{self as ast, AnyStringKind, StringLike};
|
||||||
use ruff_source_file::Locator;
|
use ruff_source_file::Locator;
|
||||||
use ruff_text_size::TextRange;
|
use ruff_text_size::{Ranged, TextRange, TextSize};
|
||||||
|
|
||||||
use crate::lex::docstring_detection::StateMachine;
|
use crate::checkers::ast::Checker;
|
||||||
|
use crate::rules::flake8_quotes;
|
||||||
use crate::settings::LinterSettings;
|
use crate::settings::LinterSettings;
|
||||||
|
|
||||||
use super::super::helpers::{contains_escaped_quote, unescape_string};
|
use flake8_quotes::helpers::{contains_escaped_quote, raw_contents, unescape_string};
|
||||||
use super::super::settings::Quote;
|
use flake8_quotes::settings::Quote;
|
||||||
|
|
||||||
/// ## What it does
|
/// ## What it does
|
||||||
/// Checks for strings that include escaped quotes, and suggests changing
|
/// Checks for strings that include escaped quotes, and suggests changing
|
||||||
|
@ -49,197 +50,303 @@ impl AlwaysFixableViolation for AvoidableEscapedQuote {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FStringContext {
|
/// Q003
|
||||||
/// Whether to check for escaped quotes in the f-string.
|
pub(crate) fn avoidable_escaped_quote(checker: &mut Checker, string_like: StringLike) {
|
||||||
check_for_escaped_quote: bool,
|
if checker.semantic().in_docstring()
|
||||||
/// The range of the f-string start token.
|
|| checker.semantic().in_string_type_definition()
|
||||||
start_range: TextRange,
|
// This rule has support for strings nested inside another f-strings but they're checked
|
||||||
/// The ranges of the f-string middle tokens containing escaped quotes.
|
// via the outermost f-string. This means that we shouldn't be checking any nested string
|
||||||
middle_ranges_with_escapes: Vec<TextRange>,
|
// or f-string.
|
||||||
}
|
|| checker.semantic().in_f_string_replacement_field()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
impl FStringContext {
|
let mut rule_checker = AvoidableEscapedQuoteChecker::new(checker.locator(), checker.settings);
|
||||||
fn new(check_for_escaped_quote: bool, fstring_start_range: TextRange) -> Self {
|
|
||||||
Self {
|
match string_like {
|
||||||
check_for_escaped_quote,
|
StringLike::String(expr) => {
|
||||||
start_range: fstring_start_range,
|
for string_literal in &expr.value {
|
||||||
middle_ranges_with_escapes: vec![],
|
rule_checker.visit_string_literal(string_literal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StringLike::Bytes(expr) => {
|
||||||
|
for bytes_literal in &expr.value {
|
||||||
|
rule_checker.visit_bytes_literal(bytes_literal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StringLike::FString(expr) => {
|
||||||
|
for part in &expr.value {
|
||||||
|
match part {
|
||||||
|
ast::FStringPart::Literal(string_literal) => {
|
||||||
|
rule_checker.visit_string_literal(string_literal);
|
||||||
|
}
|
||||||
|
ast::FStringPart::FString(f_string) => {
|
||||||
|
rule_checker.visit_f_string(f_string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the context to not check for escaped quotes, and clear any
|
checker.diagnostics.extend(rule_checker.into_diagnostics());
|
||||||
/// existing reported ranges.
|
}
|
||||||
fn ignore_escaped_quotes(&mut self) {
|
|
||||||
self.check_for_escaped_quote = false;
|
/// Checks for `Q003` violations using the [`Visitor`] implementation.
|
||||||
self.middle_ranges_with_escapes.clear();
|
#[derive(Debug)]
|
||||||
|
struct AvoidableEscapedQuoteChecker<'a> {
|
||||||
|
locator: &'a Locator<'a>,
|
||||||
|
quotes_settings: &'a flake8_quotes::settings::Settings,
|
||||||
|
supports_pep701: bool,
|
||||||
|
diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> AvoidableEscapedQuoteChecker<'a> {
|
||||||
|
fn new(locator: &'a Locator<'a>, settings: &'a LinterSettings) -> Self {
|
||||||
|
Self {
|
||||||
|
locator,
|
||||||
|
quotes_settings: &settings.flake8_quotes,
|
||||||
|
supports_pep701: settings.target_version.supports_pep701(),
|
||||||
|
diagnostics: vec![],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_fstring_middle_range(&mut self, range: TextRange) {
|
/// Consumes the checker and returns a vector of [`Diagnostic`] found during the visit.
|
||||||
self.middle_ranges_with_escapes.push(range);
|
fn into_diagnostics(self) -> Vec<Diagnostic> {
|
||||||
|
self.diagnostics
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Q003
|
impl Visitor<'_> for AvoidableEscapedQuoteChecker<'_> {
|
||||||
pub(crate) fn avoidable_escaped_quote(
|
fn visit_string_literal(&mut self, string_literal: &'_ ast::StringLiteral) {
|
||||||
diagnostics: &mut Vec<Diagnostic>,
|
if let Some(diagnostic) = check_string_or_bytes(
|
||||||
lxr: &[LexResult],
|
self.locator,
|
||||||
locator: &Locator,
|
self.quotes_settings,
|
||||||
settings: &LinterSettings,
|
string_literal.range(),
|
||||||
) {
|
AnyStringKind::from(string_literal.flags),
|
||||||
let quotes_settings = &settings.flake8_quotes;
|
) {
|
||||||
let supports_pep701 = settings.target_version.supports_pep701();
|
self.diagnostics.push(diagnostic);
|
||||||
let mut fstrings: Vec<FStringContext> = Vec::new();
|
}
|
||||||
let mut state_machine = StateMachine::default();
|
}
|
||||||
|
|
||||||
for &(ref tok, tok_range) in lxr.iter().flatten() {
|
fn visit_bytes_literal(&mut self, bytes_literal: &'_ ast::BytesLiteral) {
|
||||||
let is_docstring = state_machine.consume(tok);
|
if let Some(diagnostic) = check_string_or_bytes(
|
||||||
if is_docstring {
|
self.locator,
|
||||||
|
self.quotes_settings,
|
||||||
|
bytes_literal.range(),
|
||||||
|
AnyStringKind::from(bytes_literal.flags),
|
||||||
|
) {
|
||||||
|
self.diagnostics.push(diagnostic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_f_string(&mut self, f_string: &'_ ast::FString) {
|
||||||
|
// If the target version doesn't support PEP 701, skip this entire f-string if it contains
|
||||||
|
// any string literal in any of the expression element. For example:
|
||||||
|
//
|
||||||
|
// ```python
|
||||||
|
// f"\"foo\" {'nested'}"
|
||||||
|
// ```
|
||||||
|
//
|
||||||
|
// If we try to fix the above example, the outer and inner quote will be the same which is
|
||||||
|
// invalid for any Python version before 3.12:
|
||||||
|
//
|
||||||
|
// ```python
|
||||||
|
// f'"foo" {'nested'}"
|
||||||
|
// ```
|
||||||
|
//
|
||||||
|
// Note that this check needs to be done globally to ignore the entire f-string. It is
|
||||||
|
// implicitly global in that we avoid recursing into this f-string if this is the case.
|
||||||
|
if !self.supports_pep701 {
|
||||||
|
let contains_any_string = {
|
||||||
|
let mut visitor = ContainsAnyString::default();
|
||||||
|
// We need to use the `walk_f_string` instead of `visit_f_string` to avoid
|
||||||
|
// considering the top level f-string.
|
||||||
|
walk_f_string(&mut visitor, f_string);
|
||||||
|
visitor.result
|
||||||
|
};
|
||||||
|
if contains_any_string {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let opposite_quote_char = self.quotes_settings.inline_quotes.opposite().as_char();
|
||||||
|
|
||||||
|
// If any literal part of this f-string contains the quote character which is opposite to
|
||||||
|
// the configured inline quotes, we can't change the quote style for this f-string. For
|
||||||
|
// example:
|
||||||
|
//
|
||||||
|
// ```py
|
||||||
|
// f"\"hello\" {x} 'world'"
|
||||||
|
// ```
|
||||||
|
//
|
||||||
|
// If we try to fix the above example, the f-string will end in the middle and "world" will
|
||||||
|
// be considered as a variable which is outside this f-string:
|
||||||
|
//
|
||||||
|
// ```py
|
||||||
|
// f'"hello" {x} 'world''
|
||||||
|
// # ^
|
||||||
|
// # f-string ends here now
|
||||||
|
// ```
|
||||||
|
//
|
||||||
|
// The check is local to this f-string and it shouldn't check for any literal parts of any
|
||||||
|
// nested f-string. This is correct because by this point, we know that the target version
|
||||||
|
// is 3.12 or that this f-string doesn't have any strings nested in it. For example:
|
||||||
|
//
|
||||||
|
// ```py
|
||||||
|
// f'\'normal\' {f'\'nested\' {x} "double quotes"'} normal'
|
||||||
|
// ```
|
||||||
|
//
|
||||||
|
// This contains a nested f-string but if we reached here that means the target version
|
||||||
|
// supports PEP 701. The double quotes in the nested f-string shouldn't affect the outer
|
||||||
|
// f-string because the following is valid for Python version 3.12 and later:
|
||||||
|
//
|
||||||
|
// ```py
|
||||||
|
// f"'normal' {f'\'nested\' {x} "double quotes"'} normal"
|
||||||
|
// ```
|
||||||
|
if !f_string
|
||||||
|
.literals()
|
||||||
|
.any(|literal| contains_quote(literal, opposite_quote_char))
|
||||||
|
{
|
||||||
|
if let Some(diagnostic) = check_f_string(self.locator, self.quotes_settings, f_string) {
|
||||||
|
self.diagnostics.push(diagnostic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk_f_string(self, f_string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks for unnecessary escaped quotes in a string or bytes literal.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If the string kind is an f-string.
|
||||||
|
fn check_string_or_bytes(
|
||||||
|
locator: &Locator,
|
||||||
|
quotes_settings: &flake8_quotes::settings::Settings,
|
||||||
|
range: TextRange,
|
||||||
|
kind: AnyStringKind,
|
||||||
|
) -> Option<Diagnostic> {
|
||||||
|
assert!(!kind.is_f_string());
|
||||||
|
|
||||||
|
if kind.is_triple_quoted() || kind.is_raw_string() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're using the preferred quotation style.
|
||||||
|
if Quote::from(kind.quote_style()) != quotes_settings.inline_quotes {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = raw_contents(locator.slice(range), kind);
|
||||||
|
|
||||||
|
if !contains_escaped_quote(contents, quotes_settings.inline_quotes.as_char())
|
||||||
|
|| contains_quote(contents, quotes_settings.inline_quotes.opposite().as_char())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut diagnostic = Diagnostic::new(AvoidableEscapedQuote, range);
|
||||||
|
let fixed_contents = format!(
|
||||||
|
"{prefix}{quote}{value}{quote}",
|
||||||
|
prefix = kind.prefix(),
|
||||||
|
quote = quotes_settings.inline_quotes.opposite().as_char(),
|
||||||
|
value = unescape_string(contents, quotes_settings.inline_quotes.as_char())
|
||||||
|
);
|
||||||
|
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement(
|
||||||
|
fixed_contents,
|
||||||
|
range,
|
||||||
|
)));
|
||||||
|
Some(diagnostic)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks for unnecessary escaped quotes in an f-string.
|
||||||
|
fn check_f_string(
|
||||||
|
locator: &Locator,
|
||||||
|
quotes_settings: &flake8_quotes::settings::Settings,
|
||||||
|
f_string: &ast::FString,
|
||||||
|
) -> Option<Diagnostic> {
|
||||||
|
let ast::FString { flags, range, .. } = f_string;
|
||||||
|
|
||||||
|
if flags.is_triple_quoted() || flags.prefix().is_raw() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're using the preferred quotation style.
|
||||||
|
if Quote::from(flags.quote_style()) != quotes_settings.inline_quotes {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let quote_char = quotes_settings.inline_quotes.as_char();
|
||||||
|
let opposite_quote_char = quotes_settings.inline_quotes.opposite().as_char();
|
||||||
|
|
||||||
|
let mut edits = vec![];
|
||||||
|
for literal in f_string.literals() {
|
||||||
|
let content = locator.slice(literal);
|
||||||
|
if !contains_escaped_quote(content, quote_char) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
edits.push(Edit::range_replacement(
|
||||||
|
unescape_string(content, quote_char),
|
||||||
|
literal.range(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if !supports_pep701 {
|
if edits.is_empty() {
|
||||||
// If this is a string or a start of a f-string which is inside another
|
return None;
|
||||||
// f-string, we won't check for escaped quotes for the entire f-string
|
}
|
||||||
// if the target version doesn't support PEP 701. For example:
|
|
||||||
//
|
|
||||||
// ```python
|
|
||||||
// f"\"foo\" {'nested'}"
|
|
||||||
// # ^^^^^^^^
|
|
||||||
// # We're here
|
|
||||||
// ```
|
|
||||||
//
|
|
||||||
// If we try to fix the above example, the outer and inner quote
|
|
||||||
// will be the same which is invalid pre 3.12:
|
|
||||||
//
|
|
||||||
// ```python
|
|
||||||
// f'"foo" {'nested'}"
|
|
||||||
// ```
|
|
||||||
if matches!(tok, Tok::String { .. } | Tok::FStringStart(_)) {
|
|
||||||
if let Some(fstring_context) = fstrings.last_mut() {
|
|
||||||
fstring_context.ignore_escaped_quotes();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match tok {
|
// Replacement for the f-string opening quote. We don't perform the check for raw and
|
||||||
Tok::String {
|
// triple-quoted f-strings, so no need to account for them.
|
||||||
value: string_contents,
|
let start_edit = Edit::range_replacement(
|
||||||
kind,
|
format!("f{opposite_quote_char}"),
|
||||||
} => {
|
TextRange::at(
|
||||||
if kind.is_raw_string() || kind.is_triple_quoted() {
|
range.start(),
|
||||||
continue;
|
// Prefix + quote char
|
||||||
}
|
TextSize::new(2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Check if we're using the preferred quotation style.
|
// Replacement for the f-string ending quote. We don't perform the check for triple-quoted
|
||||||
if Quote::from(kind.quote_style()) != quotes_settings.inline_quotes {
|
// f-string, so no need to account for them.
|
||||||
continue;
|
edits.push(Edit::range_replacement(
|
||||||
}
|
opposite_quote_char.to_string(),
|
||||||
|
TextRange::at(
|
||||||
|
// Offset would either be the end offset of the start edit in case there are no
|
||||||
|
// elements in the f-string (e.g., `f""`) or the end offset of the last f-string
|
||||||
|
// element (e.g., `f"hello"`).
|
||||||
|
f_string
|
||||||
|
.elements
|
||||||
|
.last()
|
||||||
|
.map_or_else(|| start_edit.end(), Ranged::end),
|
||||||
|
// Quote char
|
||||||
|
TextSize::new(1),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
if contains_escaped_quote(string_contents, quotes_settings.inline_quotes.as_char())
|
Some(
|
||||||
&& !contains_quote(
|
Diagnostic::new(AvoidableEscapedQuote, *range).with_fix(Fix::safe_edits(start_edit, edits)),
|
||||||
string_contents,
|
)
|
||||||
quotes_settings.inline_quotes.opposite().as_char(),
|
}
|
||||||
)
|
|
||||||
{
|
#[derive(Debug, Default)]
|
||||||
let mut diagnostic = Diagnostic::new(AvoidableEscapedQuote, tok_range);
|
struct ContainsAnyString {
|
||||||
let fixed_contents = format!(
|
result: bool,
|
||||||
"{prefix}{quote}{value}{quote}",
|
}
|
||||||
prefix = kind.prefix(),
|
|
||||||
quote = quotes_settings.inline_quotes.opposite().as_char(),
|
impl Visitor<'_> for ContainsAnyString {
|
||||||
value = unescape_string(
|
fn visit_string_literal(&mut self, _: &'_ ast::StringLiteral) {
|
||||||
string_contents,
|
self.result = true;
|
||||||
quotes_settings.inline_quotes.as_char()
|
}
|
||||||
)
|
|
||||||
);
|
fn visit_bytes_literal(&mut self, _: &'_ ast::BytesLiteral) {
|
||||||
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement(
|
self.result = true;
|
||||||
fixed_contents,
|
}
|
||||||
tok_range,
|
|
||||||
)));
|
fn visit_f_string(&mut self, _: &'_ ast::FString) {
|
||||||
diagnostics.push(diagnostic);
|
self.result = true;
|
||||||
}
|
// We don't need to recurse into this f-string now that we already know the result.
|
||||||
}
|
|
||||||
Tok::FStringStart(kind) => {
|
|
||||||
// Check for escaped quote only if we're using the preferred quotation
|
|
||||||
// style and it isn't a triple-quoted f-string.
|
|
||||||
let check_for_escaped_quote = !kind.is_triple_quoted()
|
|
||||||
&& Quote::from(kind.quote_style()) == quotes_settings.inline_quotes;
|
|
||||||
fstrings.push(FStringContext::new(check_for_escaped_quote, tok_range));
|
|
||||||
}
|
|
||||||
Tok::FStringMiddle {
|
|
||||||
value: string_contents,
|
|
||||||
kind,
|
|
||||||
} if !kind.is_raw_string() => {
|
|
||||||
let Some(context) = fstrings.last_mut() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if !context.check_for_escaped_quote {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// If any part of the f-string contains the opposite quote,
|
|
||||||
// we can't change the quote style in the entire f-string.
|
|
||||||
if contains_quote(
|
|
||||||
string_contents,
|
|
||||||
quotes_settings.inline_quotes.opposite().as_char(),
|
|
||||||
) {
|
|
||||||
context.ignore_escaped_quotes();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if contains_escaped_quote(string_contents, quotes_settings.inline_quotes.as_char())
|
|
||||||
{
|
|
||||||
context.push_fstring_middle_range(tok_range);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Tok::FStringEnd => {
|
|
||||||
let Some(context) = fstrings.pop() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if context.middle_ranges_with_escapes.is_empty() {
|
|
||||||
// There are no `FStringMiddle` tokens containing any escaped
|
|
||||||
// quotes.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut diagnostic = Diagnostic::new(
|
|
||||||
AvoidableEscapedQuote,
|
|
||||||
TextRange::new(context.start_range.start(), tok_range.end()),
|
|
||||||
);
|
|
||||||
let fstring_start_edit = Edit::range_replacement(
|
|
||||||
// No need for `r`/`R` as we don't perform the checks
|
|
||||||
// for raw strings.
|
|
||||||
format!("f{}", quotes_settings.inline_quotes.opposite().as_char()),
|
|
||||||
context.start_range,
|
|
||||||
);
|
|
||||||
let fstring_middle_and_end_edits = context
|
|
||||||
.middle_ranges_with_escapes
|
|
||||||
.iter()
|
|
||||||
.map(|&range| {
|
|
||||||
Edit::range_replacement(
|
|
||||||
unescape_string(
|
|
||||||
locator.slice(range),
|
|
||||||
quotes_settings.inline_quotes.as_char(),
|
|
||||||
),
|
|
||||||
range,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.chain(std::iter::once(
|
|
||||||
// `FStringEnd` edit
|
|
||||||
Edit::range_replacement(
|
|
||||||
quotes_settings
|
|
||||||
.inline_quotes
|
|
||||||
.opposite()
|
|
||||||
.as_char()
|
|
||||||
.to_string(),
|
|
||||||
tok_range,
|
|
||||||
),
|
|
||||||
));
|
|
||||||
diagnostic.set_fix(Fix::safe_edits(
|
|
||||||
fstring_start_edit,
|
|
||||||
fstring_middle_and_end_edits,
|
|
||||||
));
|
|
||||||
diagnostics.push(diagnostic);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,11 @@ use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
|
||||||
use ruff_macros::{derive_message_formats, violation};
|
use ruff_macros::{derive_message_formats, violation};
|
||||||
use ruff_python_ast::{self as ast, AnyStringKind, StringLike};
|
use ruff_python_ast::{self as ast, AnyStringKind, StringLike};
|
||||||
use ruff_source_file::Locator;
|
use ruff_source_file::Locator;
|
||||||
use ruff_text_size::{Ranged, TextLen, TextRange};
|
use ruff_text_size::{Ranged, TextRange};
|
||||||
|
|
||||||
use crate::checkers::ast::Checker;
|
use crate::checkers::ast::Checker;
|
||||||
|
|
||||||
use super::super::helpers::{contains_escaped_quote, unescape_string};
|
use super::super::helpers::{contains_escaped_quote, raw_contents, unescape_string};
|
||||||
|
|
||||||
/// ## What it does
|
/// ## What it does
|
||||||
/// Checks for strings that include unnecessarily escaped quotes.
|
/// Checks for strings that include unnecessarily escaped quotes.
|
||||||
|
@ -47,6 +47,10 @@ impl AlwaysFixableViolation for UnnecessaryEscapedQuote {
|
||||||
|
|
||||||
/// Q004
|
/// Q004
|
||||||
pub(crate) fn unnecessary_escaped_quote(checker: &mut Checker, string_like: StringLike) {
|
pub(crate) fn unnecessary_escaped_quote(checker: &mut Checker, string_like: StringLike) {
|
||||||
|
if checker.semantic().in_docstring() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let locator = checker.locator();
|
let locator = checker.locator();
|
||||||
|
|
||||||
match string_like {
|
match string_like {
|
||||||
|
@ -147,9 +151,3 @@ fn check_f_string(locator: &Locator, f_string: &ast::FString) -> Option<Diagnost
|
||||||
diagnostic.set_fix(Fix::safe_edits(first, edits_iter));
|
diagnostic.set_fix(Fix::safe_edits(first, edits_iter));
|
||||||
Some(diagnostic)
|
Some(diagnostic)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the raw contents of the string given the string's contents and kind.
|
|
||||||
/// This is a string without the prefix and quotes.
|
|
||||||
fn raw_contents(contents: &str, kind: AnyStringKind) -> &str {
|
|
||||||
&contents[kind.opener_len().to_usize()..(contents.text_len() - kind.closer_len()).to_usize()]
|
|
||||||
}
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue