From fbf231e1b8e4a5851a7a3407fa4ef8ba268ed01f Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 31 Jan 2023 16:27:15 -0500 Subject: [PATCH] Allow implicit multiline strings with internal quotes to use non-preferred quote (#2416) As an example, if you have `single` as your preferred style, we'll now allow this: ```py assert s.to_python(123) == ( "123 info=SerializationInfo(include=None, exclude=None, mode='python', by_alias=True, exclude_unset=False, " "exclude_defaults=False, exclude_none=False, round_trip=False)" ) ``` Previously, the second line of the implicit string concatenation would be flagged as invalid, despite the _first_ line requiring double quotes. (Note that we'll accept either single or double quotes for that second line.) Mechanically, this required that we process sequences of `Tok::String` rather than a single `Tok::String` at a time. Prior to iterating over the strings in the sequence, we check if any of them require the non-preferred quote style; if so, we let _any_ of them use it. Closes #2400. --- .../test/fixtures/flake8_quotes/doubles.py | 2 + .../fixtures/flake8_quotes/doubles_escaped.py | 5 + .../flake8_quotes/doubles_implicit.py | 27 ++ .../test/fixtures/flake8_quotes/singles.py | 2 + .../fixtures/flake8_quotes/singles_escaped.py | 5 + .../flake8_quotes/singles_implicit.py | 27 ++ src/checkers/tokens.rs | 112 +++-- src/rules/flake8_quotes/mod.rs | 2 + src/rules/flake8_quotes/rules.rs | 457 +++++++++++------- ...ests__require_doubles_over_singles.py.snap | 19 + ...quire_doubles_over_singles_escaped.py.snap | 18 + ...uire_doubles_over_singles_implicit.py.snap | 138 ++++++ ...ests__require_singles_over_doubles.py.snap | 19 + ...quire_singles_over_doubles_escaped.py.snap | 18 + ...uire_singles_over_doubles_implicit.py.snap | 138 ++++++ 15 files changed, 766 insertions(+), 223 deletions(-) create mode 100644 resources/test/fixtures/flake8_quotes/doubles_implicit.py create mode 100644 resources/test/fixtures/flake8_quotes/singles_implicit.py create mode 100644 src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_doubles_over_singles_implicit.py.snap create mode 100644 src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_singles_over_doubles_implicit.py.snap diff --git a/resources/test/fixtures/flake8_quotes/doubles.py b/resources/test/fixtures/flake8_quotes/doubles.py index cf4e54f7cd..d2a7a2bf91 100644 --- a/resources/test/fixtures/flake8_quotes/doubles.py +++ b/resources/test/fixtures/flake8_quotes/doubles.py @@ -1,2 +1,4 @@ this_should_be_linted = "double quote string" this_should_be_linted = u"double quote string" +this_should_be_linted = f"double quote string" +this_should_be_linted = f"double {'quote'} string" diff --git a/resources/test/fixtures/flake8_quotes/doubles_escaped.py b/resources/test/fixtures/flake8_quotes/doubles_escaped.py index 401a7fcba6..c55ba00cd8 100644 --- a/resources/test/fixtures/flake8_quotes/doubles_escaped.py +++ b/resources/test/fixtures/flake8_quotes/doubles_escaped.py @@ -4,3 +4,8 @@ this_is_fine = '"This" is a \'string\'' this_is_fine = "This is a 'string'" this_is_fine = "\"This\" is a 'string'" this_is_fine = r'This is a \'string\'' +this_is_fine = R'This is a \'string\'' +this_should_raise = ( + 'This is a' + '\'string\'' +) diff --git a/resources/test/fixtures/flake8_quotes/doubles_implicit.py b/resources/test/fixtures/flake8_quotes/doubles_implicit.py new file mode 100644 index 0000000000..db19c24b5f --- /dev/null +++ b/resources/test/fixtures/flake8_quotes/doubles_implicit.py @@ -0,0 +1,27 @@ +x = ( + "This" + "is" + "not" +) + +x = ( + "This" \ + "is" \ + "not" +) + +x = ( + "This" + "is 'actually'" + "fine" +) + +x = ( + "This" \ + "is 'actually'" \ + "fine" +) + +if True: + "This can use 'double' quotes" +"But this needs to be changed" diff --git a/resources/test/fixtures/flake8_quotes/singles.py b/resources/test/fixtures/flake8_quotes/singles.py index 9888a0c707..0639e885fc 100644 --- a/resources/test/fixtures/flake8_quotes/singles.py +++ b/resources/test/fixtures/flake8_quotes/singles.py @@ -1,2 +1,4 @@ this_should_be_linted = 'single quote string' this_should_be_linted = u'double quote string' +this_should_be_linted = f'double quote string' +this_should_be_linted = f'double {"quote"} string' diff --git a/resources/test/fixtures/flake8_quotes/singles_escaped.py b/resources/test/fixtures/flake8_quotes/singles_escaped.py index fa615d78fb..f011c5f90c 100644 --- a/resources/test/fixtures/flake8_quotes/singles_escaped.py +++ b/resources/test/fixtures/flake8_quotes/singles_escaped.py @@ -3,3 +3,8 @@ this_is_fine = "'This' is a \"string\"" this_is_fine = 'This is a "string"' this_is_fine = '\'This\' is a "string"' this_is_fine = r"This is a \"string\"" +this_is_fine = R"This is a \"string\"" +this_should_raise = ( + "This is a" + "\"string\"" +) diff --git a/resources/test/fixtures/flake8_quotes/singles_implicit.py b/resources/test/fixtures/flake8_quotes/singles_implicit.py new file mode 100644 index 0000000000..146fda7fa8 --- /dev/null +++ b/resources/test/fixtures/flake8_quotes/singles_implicit.py @@ -0,0 +1,27 @@ +x = ( + 'This' + 'is' + 'not' +) + +x = ( + 'This' \ + 'is' \ + 'not' +) + +x = ( + 'This' + 'is "actually"' + 'fine' +) + +x = ( + 'This' \ + 'is "actually"' \ + 'fine' +) + +if True: + 'This can use "single" quotes' +'But this needs to be changed' diff --git a/src/checkers/tokens.rs b/src/checkers/tokens.rs index a8e8fd66d1..5f9c25280e 100644 --- a/src/checkers/tokens.rs +++ b/src/checkers/tokens.rs @@ -48,77 +48,73 @@ pub fn check_tokens( || settings.rules.enabled(&Rule::TrailingCommaProhibited); let enforce_extraneous_parenthesis = settings.rules.enabled(&Rule::ExtraneousParentheses); - let mut state_machine = StateMachine::default(); - for &(start, ref tok, end) in tokens.iter().flatten() { - let is_docstring = if enforce_ambiguous_unicode_character || enforce_quotes { - state_machine.consume(tok) - } else { - false - }; + if enforce_ambiguous_unicode_character + || enforce_commented_out_code + || enforce_invalid_escape_sequence + { + let mut state_machine = StateMachine::default(); + for &(start, ref tok, end) in tokens.iter().flatten() { + let is_docstring = if enforce_ambiguous_unicode_character { + state_machine.consume(tok) + } else { + false + }; - // RUF001, RUF002, RUF003 - if enforce_ambiguous_unicode_character { - if matches!(tok, Tok::String { .. } | Tok::Comment(_)) { - diagnostics.extend(ruff::rules::ambiguous_unicode_character( - locator, - start, - end, - if matches!(tok, Tok::String { .. }) { - if is_docstring { - Context::Docstring + // RUF001, RUF002, RUF003 + if enforce_ambiguous_unicode_character { + if matches!(tok, Tok::String { .. } | Tok::Comment(_)) { + diagnostics.extend(ruff::rules::ambiguous_unicode_character( + locator, + start, + end, + if matches!(tok, Tok::String { .. }) { + if is_docstring { + Context::Docstring + } else { + Context::String + } } else { - Context::String - } - } else { - Context::Comment - }, - settings, - autofix, - )); + Context::Comment + }, + settings, + autofix, + )); + } } - } - // flake8-quotes - if enforce_quotes { - if matches!(tok, Tok::String { .. }) { - if let Some(diagnostic) = flake8_quotes::rules::quotes( - locator, - start, - end, - is_docstring, - settings, - autofix, - ) { - if settings.rules.enabled(diagnostic.kind.rule()) { + // eradicate + if enforce_commented_out_code { + if matches!(tok, Tok::Comment(_)) { + if let Some(diagnostic) = + eradicate::rules::commented_out_code(locator, start, end, settings, autofix) + { diagnostics.push(diagnostic); } } } - } - // eradicate - if enforce_commented_out_code { - if matches!(tok, Tok::Comment(_)) { - if let Some(diagnostic) = - eradicate::rules::commented_out_code(locator, start, end, settings, autofix) - { - diagnostics.push(diagnostic); + // W605 + if enforce_invalid_escape_sequence { + if matches!(tok, Tok::String { .. }) { + diagnostics.extend(pycodestyle::rules::invalid_escape_sequence( + locator, + start, + end, + matches!(autofix, flags::Autofix::Enabled) + && settings.rules.should_fix(&Rule::InvalidEscapeSequence), + )); } } } + } - // W605 - if enforce_invalid_escape_sequence { - if matches!(tok, Tok::String { .. }) { - diagnostics.extend(pycodestyle::rules::invalid_escape_sequence( - locator, - start, - end, - matches!(autofix, flags::Autofix::Enabled) - && settings.rules.should_fix(&Rule::InvalidEscapeSequence), - )); - } - } + // Q001, Q002, Q003 + if enforce_quotes { + diagnostics.extend( + flake8_quotes::rules::from_tokens(tokens, locator, settings, autofix) + .into_iter() + .filter(|diagnostic| settings.rules.enabled(diagnostic.kind.rule())), + ); } // ISC001, ISC002 diff --git a/src/rules/flake8_quotes/mod.rs b/src/rules/flake8_quotes/mod.rs index 435445397a..2470be171a 100644 --- a/src/rules/flake8_quotes/mod.rs +++ b/src/rules/flake8_quotes/mod.rs @@ -17,6 +17,7 @@ mod tests { #[test_case(Path::new("doubles.py"))] #[test_case(Path::new("doubles_escaped.py"))] + #[test_case(Path::new("doubles_implicit.py"))] #[test_case(Path::new("doubles_multiline_string.py"))] #[test_case(Path::new("doubles_noqa.py"))] #[test_case(Path::new("doubles_wrapped.py"))] @@ -47,6 +48,7 @@ mod tests { #[test_case(Path::new("singles.py"))] #[test_case(Path::new("singles_escaped.py"))] + #[test_case(Path::new("singles_implicit.py"))] #[test_case(Path::new("singles_multiline_string.py"))] #[test_case(Path::new("singles_noqa.py"))] #[test_case(Path::new("singles_wrapped.py"))] diff --git a/src/rules/flake8_quotes/rules.rs b/src/rules/flake8_quotes/rules.rs index 676b7699e2..9ace32f242 100644 --- a/src/rules/flake8_quotes/rules.rs +++ b/src/rules/flake8_quotes/rules.rs @@ -1,13 +1,16 @@ use rustpython_ast::Location; +use rustpython_parser::lexer::{LexResult, Tok}; -use super::settings::Quote; use crate::ast::types::Range; use crate::fix::Fix; +use crate::lex::docstring_detection::StateMachine; use crate::registry::{Diagnostic, Rule}; use crate::settings::{flags, Settings}; use crate::source_code::Locator; use crate::violations; +use super::settings::Quote; + fn good_single(quote: &Quote) -> char { match quote { Quote::Single => '\'', @@ -43,178 +46,302 @@ fn good_docstring(quote: &Quote) -> &str { } } -pub fn quotes( +struct Trivia<'a> { + last_quote_char: char, + prefix: &'a str, + raw_text: &'a str, + is_multiline: bool, +} + +impl<'a> From<&'a str> for Trivia<'a> { + fn from(value: &'a str) -> Self { + // Remove any prefixes (e.g., remove `u` from `u"foo"`). + let last_quote_char = value.chars().last().unwrap(); + let first_quote_char = value.find(last_quote_char).unwrap(); + let prefix = &value[..first_quote_char]; + let raw_text = &value[first_quote_char..]; + + // Determine if the string is multiline-based. + let is_multiline = if raw_text.len() >= 3 { + let mut chars = raw_text.chars(); + let first = chars.next().unwrap(); + let second = chars.next().unwrap(); + let third = chars.next().unwrap(); + first == second && second == third + } else { + false + }; + + Self { + last_quote_char, + prefix, + raw_text, + is_multiline, + } + } +} + +/// Q003 +fn docstring( locator: &Locator, start: Location, end: Location, - is_docstring: bool, settings: &Settings, autofix: flags::Autofix, ) -> Option { let quotes_settings = &settings.flake8_quotes; + let text = locator.slice_source_code_range(&Range::new(start, end)); + let trivia: Trivia = text.into(); - // Remove any prefixes (e.g., remove `u` from `u"foo"`). - let last_quote_char = text.chars().last().unwrap(); - let first_quote_char = text.find(last_quote_char).unwrap(); - let prefix = &text[..first_quote_char].to_lowercase(); - let raw_text = &text[first_quote_char..]; - - // Determine if the string is multiline-based. - let is_multiline = if raw_text.len() >= 3 { - let mut chars = raw_text.chars(); - let first = chars.next().unwrap(); - let second = chars.next().unwrap(); - let third = chars.next().unwrap(); - first == second && second == third - } else { - false - }; - - if is_docstring { - if raw_text.contains(good_docstring("es_settings.docstring_quotes)) { - return None; - } - - let mut diagnostic = Diagnostic::new( - violations::BadQuotesDocstring { - quote: quotes_settings.docstring_quotes.clone(), - }, - Range::new(start, end), - ); - if matches!(autofix, flags::Autofix::Enabled) - && settings.rules.should_fix(&Rule::BadQuotesDocstring) - { - let quote_count = if is_multiline { 3 } else { 1 }; - let string_contents = &raw_text[quote_count..raw_text.len() - quote_count]; - let quote = good_docstring("es_settings.docstring_quotes).repeat(quote_count); - let mut fixed_contents = - String::with_capacity(prefix.len() + string_contents.len() + quote.len() * 2); - fixed_contents.push_str(prefix); - fixed_contents.push_str("e); - fixed_contents.push_str(string_contents); - fixed_contents.push_str("e); - diagnostic.amend(Fix::replacement(fixed_contents, start, end)); - } - Some(diagnostic) - } else if is_multiline { - // If our string is or contains a known good string, ignore it. - if raw_text.contains(good_multiline("es_settings.multiline_quotes)) { - return None; - } - - // If our string ends with a known good ending, then ignore it. - if raw_text.ends_with(good_multiline_ending("es_settings.multiline_quotes)) { - return None; - } - - let mut diagnostic = Diagnostic::new( - violations::BadQuotesMultilineString { - quote: quotes_settings.multiline_quotes.clone(), - }, - Range::new(start, end), - ); - - if matches!(autofix, flags::Autofix::Enabled) - && settings.rules.should_fix(&Rule::BadQuotesMultilineString) - { - let string_contents = &raw_text[3..raw_text.len() - 3]; - let quote = good_multiline("es_settings.multiline_quotes); - let mut fixed_contents = - String::with_capacity(prefix.len() + string_contents.len() + quote.len() * 2); - fixed_contents.push_str(prefix); - fixed_contents.push_str(quote); - fixed_contents.push_str(string_contents); - fixed_contents.push_str(quote); - diagnostic.amend(Fix::replacement(fixed_contents, start, end)); - } - Some(diagnostic) - } else { - let string_contents = &raw_text[1..raw_text.len() - 1]; - - // If we're using the preferred quotation type, check for escapes. - if last_quote_char == good_single("es_settings.inline_quotes) { - if !quotes_settings.avoid_escape || prefix.contains('r') { - return None; - } - if string_contents.contains(good_single("es_settings.inline_quotes)) - && !string_contents.contains(bad_single("es_settings.inline_quotes)) - { - let mut diagnostic = - Diagnostic::new(violations::AvoidQuoteEscape, Range::new(start, end)); - if matches!(autofix, flags::Autofix::Enabled) - && settings.rules.should_fix(&Rule::AvoidQuoteEscape) - { - let quote = bad_single("es_settings.inline_quotes); - - let mut fixed_contents = - String::with_capacity(prefix.len() + string_contents.len() + 2); - fixed_contents.push_str(prefix); - fixed_contents.push(quote); - - let chars: Vec = string_contents.chars().collect(); - let mut backslash_count = 0; - for col_offset in 0..chars.len() { - let char = chars[col_offset]; - if char != '\\' { - fixed_contents.push(char); - continue; - } - backslash_count += 1; - // If the previous character was also a backslash - if col_offset > 0 && chars[col_offset - 1] == '\\' && backslash_count == 2 { - fixed_contents.push(char); - // reset to 0 - backslash_count = 0; - continue; - } - // If we're at the end of the line - if col_offset == chars.len() - 1 { - fixed_contents.push(char); - continue; - } - let next_char = chars[col_offset + 1]; - // Remove quote escape - if next_char == '\'' || next_char == '"' { - // reset to 0 - backslash_count = 0; - continue; - } - fixed_contents.push(char); - } - - fixed_contents.push(quote); - - diagnostic.amend(Fix::replacement(fixed_contents, start, end)); - } - return Some(diagnostic); - } - return None; - } - - // If we're not using the preferred type, only allow use to avoid escapes. - if !string_contents.contains(good_single("es_settings.inline_quotes)) { - let mut diagnostic = Diagnostic::new( - violations::BadQuotesInlineString { - quote: quotes_settings.inline_quotes.clone(), - }, - Range::new(start, end), - ); - if matches!(autofix, flags::Autofix::Enabled) - && settings.rules.should_fix(&Rule::BadQuotesInlineString) - { - let quote = good_single("es_settings.inline_quotes); - let mut fixed_contents = - String::with_capacity(prefix.len() + string_contents.len() + 2); - fixed_contents.push_str(prefix); - fixed_contents.push(quote); - fixed_contents.push_str(string_contents); - fixed_contents.push(quote); - diagnostic.amend(Fix::replacement(fixed_contents, start, end)); - } - return Some(diagnostic); - } - - None + if trivia + .raw_text + .contains(good_docstring("es_settings.docstring_quotes)) + { + return None; } + + let mut diagnostic = Diagnostic::new( + violations::BadQuotesDocstring { + quote: quotes_settings.docstring_quotes.clone(), + }, + Range::new(start, end), + ); + if matches!(autofix, flags::Autofix::Enabled) + && settings.rules.should_fix(&Rule::BadQuotesDocstring) + { + let quote_count = if trivia.is_multiline { 3 } else { 1 }; + let string_contents = &trivia.raw_text[quote_count..trivia.raw_text.len() - quote_count]; + let quote = good_docstring("es_settings.docstring_quotes).repeat(quote_count); + let mut fixed_contents = + String::with_capacity(trivia.prefix.len() + string_contents.len() + quote.len() * 2); + fixed_contents.push_str(trivia.prefix); + fixed_contents.push_str("e); + fixed_contents.push_str(string_contents); + fixed_contents.push_str("e); + diagnostic.amend(Fix::replacement(fixed_contents, start, end)); + } + Some(diagnostic) +} + +/// Q001, Q002 +fn strings( + locator: &Locator, + sequence: &[(Location, Location)], + settings: &Settings, + autofix: flags::Autofix, +) -> Vec { + let mut diagnostics = vec![]; + + let quotes_settings = &settings.flake8_quotes; + + let trivia = sequence + .iter() + .map(|(start, end)| { + let text = locator.slice_source_code_range(&Range::new(*start, *end)); + let trivia: Trivia = text.into(); + trivia + }) + .collect::>(); + + // Return `true` if any of the strings are inline strings that contain the quote character in + // the body. + let relax_quote = trivia.iter().any(|trivia| { + if trivia.is_multiline { + return false; + } + + if trivia.last_quote_char == good_single("es_settings.inline_quotes) { + return false; + } + + let string_contents = &trivia.raw_text[1..trivia.raw_text.len() - 1]; + string_contents.contains(good_single("es_settings.inline_quotes)) + }); + + for ((start, end), trivia) in sequence.iter().zip(trivia.into_iter()) { + if trivia.is_multiline { + // If our string is or contains a known good string, ignore it. + if trivia + .raw_text + .contains(good_multiline("es_settings.multiline_quotes)) + { + continue; + } + + // If our string ends with a known good ending, then ignore it. + if trivia + .raw_text + .ends_with(good_multiline_ending("es_settings.multiline_quotes)) + { + continue; + } + + let mut diagnostic = Diagnostic::new( + violations::BadQuotesMultilineString { + quote: quotes_settings.multiline_quotes.clone(), + }, + Range::new(*start, *end), + ); + + if matches!(autofix, flags::Autofix::Enabled) + && settings.rules.should_fix(&Rule::BadQuotesMultilineString) + { + let string_contents = &trivia.raw_text[3..trivia.raw_text.len() - 3]; + let quote = good_multiline("es_settings.multiline_quotes); + let mut fixed_contents = String::with_capacity( + trivia.prefix.len() + string_contents.len() + quote.len() * 2, + ); + fixed_contents.push_str(trivia.prefix); + fixed_contents.push_str(quote); + fixed_contents.push_str(string_contents); + fixed_contents.push_str(quote); + diagnostic.amend(Fix::replacement(fixed_contents, *start, *end)); + } + diagnostics.push(diagnostic); + } else { + let string_contents = &trivia.raw_text[1..trivia.raw_text.len() - 1]; + + // If we're using the preferred quotation type, check for escapes. + if trivia.last_quote_char == good_single("es_settings.inline_quotes) { + if !quotes_settings.avoid_escape + || trivia.prefix.contains('r') + || trivia.prefix.contains('R') + { + continue; + } + + if string_contents.contains(good_single("es_settings.inline_quotes)) + && !string_contents.contains(bad_single("es_settings.inline_quotes)) + { + let mut diagnostic = + Diagnostic::new(violations::AvoidQuoteEscape, Range::new(*start, *end)); + if matches!(autofix, flags::Autofix::Enabled) + && settings.rules.should_fix(&Rule::AvoidQuoteEscape) + { + let quote = bad_single("es_settings.inline_quotes); + + let mut fixed_contents = + String::with_capacity(trivia.prefix.len() + string_contents.len() + 2); + fixed_contents.push_str(trivia.prefix); + fixed_contents.push(quote); + + let chars: Vec = string_contents.chars().collect(); + let mut backslash_count = 0; + for col_offset in 0..chars.len() { + let char = chars[col_offset]; + if char != '\\' { + fixed_contents.push(char); + continue; + } + backslash_count += 1; + // If the previous character was also a backslash + if col_offset > 0 + && chars[col_offset - 1] == '\\' + && backslash_count == 2 + { + fixed_contents.push(char); + // reset to 0 + backslash_count = 0; + continue; + } + // If we're at the end of the line + if col_offset == chars.len() - 1 { + fixed_contents.push(char); + continue; + } + let next_char = chars[col_offset + 1]; + // Remove quote escape + if next_char == '\'' || next_char == '"' { + // reset to 0 + backslash_count = 0; + continue; + } + fixed_contents.push(char); + } + + fixed_contents.push(quote); + + diagnostic.amend(Fix::replacement(fixed_contents, *start, *end)); + } + diagnostics.push(diagnostic); + } + continue; + } + + // If we're not using the preferred type, only allow use to avoid escapes. + if !relax_quote { + let mut diagnostic = Diagnostic::new( + violations::BadQuotesInlineString { + quote: quotes_settings.inline_quotes.clone(), + }, + Range::new(*start, *end), + ); + if matches!(autofix, flags::Autofix::Enabled) + && settings.rules.should_fix(&Rule::BadQuotesInlineString) + { + let quote = good_single("es_settings.inline_quotes); + let mut fixed_contents = + String::with_capacity(trivia.prefix.len() + string_contents.len() + 2); + fixed_contents.push_str(trivia.prefix); + fixed_contents.push(quote); + fixed_contents.push_str(string_contents); + fixed_contents.push(quote); + diagnostic.amend(Fix::replacement(fixed_contents, *start, *end)); + } + diagnostics.push(diagnostic); + } + } + } + + diagnostics +} + +/// Generate `flake8-quote` diagnostics from a token stream. +pub fn from_tokens( + lxr: &[LexResult], + locator: &Locator, + settings: &Settings, + autofix: flags::Autofix, +) -> Vec { + let mut diagnostics = vec![]; + + // Keep track of sequences of strings, which represent implicit string concatenation, and + // should thus be handled as a single unit. + let mut sequence = vec![]; + let mut state_machine = StateMachine::default(); + for &(start, ref tok, end) in lxr.iter().flatten() { + let is_docstring = state_machine.consume(tok); + + // If this is a docstring, consume the existing sequence, then consume the docstring, then + // move on. + if is_docstring { + if !sequence.is_empty() { + diagnostics.extend(strings(locator, &sequence, settings, autofix)); + sequence.clear(); + } + if let Some(diagnostic) = docstring(locator, start, end, settings, autofix) { + diagnostics.push(diagnostic); + } + } else { + if matches!(tok, Tok::String { .. }) { + // If this is a string, add it to the sequence. + sequence.push((start, end)); + } else if !matches!(tok, Tok::Comment(..) | Tok::NonLogicalNewline) { + // Otherwise, consume the sequence. + if !sequence.is_empty() { + diagnostics.extend(strings(locator, &sequence, settings, autofix)); + sequence.clear(); + } + } + } + } + + // If we have an unterminated sequence, consume it. + if !sequence.is_empty() { + diagnostics.extend(strings(locator, &sequence, settings, autofix)); + sequence.clear(); + } + + diagnostics } diff --git a/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_doubles_over_singles.py.snap b/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_doubles_over_singles.py.snap index a42181fcad..971368cc82 100644 --- a/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_doubles_over_singles.py.snap +++ b/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_doubles_over_singles.py.snap @@ -40,4 +40,23 @@ expression: diagnostics row: 2 column: 46 parent: ~ +- kind: + BadQuotesInlineString: + quote: double + location: + row: 3 + column: 24 + end_location: + row: 3 + column: 46 + fix: + content: + - "f\"double quote string\"" + location: + row: 3 + column: 24 + end_location: + row: 3 + column: 46 + parent: ~ diff --git a/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_doubles_over_singles_escaped.py.snap b/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_doubles_over_singles_escaped.py.snap index 81fb0a6004..025511e4fe 100644 --- a/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_doubles_over_singles_escaped.py.snap +++ b/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_doubles_over_singles_escaped.py.snap @@ -20,4 +20,22 @@ expression: diagnostics row: 1 column: 47 parent: ~ +- kind: + AvoidQuoteEscape: ~ + location: + row: 9 + column: 4 + end_location: + row: 9 + column: 16 + fix: + content: + - "'\"string\"'" + location: + row: 9 + column: 4 + end_location: + row: 9 + column: 16 + parent: ~ diff --git a/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_doubles_over_singles_implicit.py.snap b/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_doubles_over_singles_implicit.py.snap new file mode 100644 index 0000000000..4a35ad1bc5 --- /dev/null +++ b/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_doubles_over_singles_implicit.py.snap @@ -0,0 +1,138 @@ +--- +source: src/rules/flake8_quotes/mod.rs +expression: diagnostics +--- +- kind: + BadQuotesInlineString: + quote: double + location: + row: 2 + column: 4 + end_location: + row: 2 + column: 10 + fix: + content: + - "\"This\"" + location: + row: 2 + column: 4 + end_location: + row: 2 + column: 10 + parent: ~ +- kind: + BadQuotesInlineString: + quote: double + location: + row: 3 + column: 4 + end_location: + row: 3 + column: 8 + fix: + content: + - "\"is\"" + location: + row: 3 + column: 4 + end_location: + row: 3 + column: 8 + parent: ~ +- kind: + BadQuotesInlineString: + quote: double + location: + row: 4 + column: 4 + end_location: + row: 4 + column: 9 + fix: + content: + - "\"not\"" + location: + row: 4 + column: 4 + end_location: + row: 4 + column: 9 + parent: ~ +- kind: + BadQuotesInlineString: + quote: double + location: + row: 8 + column: 4 + end_location: + row: 8 + column: 10 + fix: + content: + - "\"This\"" + location: + row: 8 + column: 4 + end_location: + row: 8 + column: 10 + parent: ~ +- kind: + BadQuotesInlineString: + quote: double + location: + row: 9 + column: 4 + end_location: + row: 9 + column: 8 + fix: + content: + - "\"is\"" + location: + row: 9 + column: 4 + end_location: + row: 9 + column: 8 + parent: ~ +- kind: + BadQuotesInlineString: + quote: double + location: + row: 10 + column: 4 + end_location: + row: 10 + column: 9 + fix: + content: + - "\"not\"" + location: + row: 10 + column: 4 + end_location: + row: 10 + column: 9 + parent: ~ +- kind: + BadQuotesInlineString: + quote: double + location: + row: 27 + column: 0 + end_location: + row: 27 + column: 30 + fix: + content: + - "\"But this needs to be changed\"" + location: + row: 27 + column: 0 + end_location: + row: 27 + column: 30 + parent: ~ + diff --git a/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_singles_over_doubles.py.snap b/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_singles_over_doubles.py.snap index 9c3471af92..14140205e2 100644 --- a/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_singles_over_doubles.py.snap +++ b/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_singles_over_doubles.py.snap @@ -40,4 +40,23 @@ expression: diagnostics row: 2 column: 46 parent: ~ +- kind: + BadQuotesInlineString: + quote: single + location: + row: 3 + column: 24 + end_location: + row: 3 + column: 46 + fix: + content: + - "f'double quote string'" + location: + row: 3 + column: 24 + end_location: + row: 3 + column: 46 + parent: ~ diff --git a/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_singles_over_doubles_escaped.py.snap b/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_singles_over_doubles_escaped.py.snap index 8c0edddb3b..7f6abb7187 100644 --- a/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_singles_over_doubles_escaped.py.snap +++ b/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_singles_over_doubles_escaped.py.snap @@ -38,4 +38,22 @@ expression: diagnostics row: 2 column: 52 parent: ~ +- kind: + AvoidQuoteEscape: ~ + location: + row: 10 + column: 4 + end_location: + row: 10 + column: 16 + fix: + content: + - "\"'string'\"" + location: + row: 10 + column: 4 + end_location: + row: 10 + column: 16 + parent: ~ diff --git a/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_singles_over_doubles_implicit.py.snap b/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_singles_over_doubles_implicit.py.snap new file mode 100644 index 0000000000..711a82aaac --- /dev/null +++ b/src/rules/flake8_quotes/snapshots/ruff__rules__flake8_quotes__tests__require_singles_over_doubles_implicit.py.snap @@ -0,0 +1,138 @@ +--- +source: src/rules/flake8_quotes/mod.rs +expression: diagnostics +--- +- kind: + BadQuotesInlineString: + quote: single + location: + row: 2 + column: 4 + end_location: + row: 2 + column: 10 + fix: + content: + - "'This'" + location: + row: 2 + column: 4 + end_location: + row: 2 + column: 10 + parent: ~ +- kind: + BadQuotesInlineString: + quote: single + location: + row: 3 + column: 4 + end_location: + row: 3 + column: 8 + fix: + content: + - "'is'" + location: + row: 3 + column: 4 + end_location: + row: 3 + column: 8 + parent: ~ +- kind: + BadQuotesInlineString: + quote: single + location: + row: 4 + column: 4 + end_location: + row: 4 + column: 9 + fix: + content: + - "'not'" + location: + row: 4 + column: 4 + end_location: + row: 4 + column: 9 + parent: ~ +- kind: + BadQuotesInlineString: + quote: single + location: + row: 8 + column: 4 + end_location: + row: 8 + column: 10 + fix: + content: + - "'This'" + location: + row: 8 + column: 4 + end_location: + row: 8 + column: 10 + parent: ~ +- kind: + BadQuotesInlineString: + quote: single + location: + row: 9 + column: 4 + end_location: + row: 9 + column: 8 + fix: + content: + - "'is'" + location: + row: 9 + column: 4 + end_location: + row: 9 + column: 8 + parent: ~ +- kind: + BadQuotesInlineString: + quote: single + location: + row: 10 + column: 4 + end_location: + row: 10 + column: 9 + fix: + content: + - "'not'" + location: + row: 10 + column: 4 + end_location: + row: 10 + column: 9 + parent: ~ +- kind: + BadQuotesInlineString: + quote: single + location: + row: 27 + column: 0 + end_location: + row: 27 + column: 30 + fix: + content: + - "'But this needs to be changed'" + location: + row: 27 + column: 0 + end_location: + row: 27 + column: 30 + parent: ~ +