diff --git a/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs b/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs index b182b36e899e19..ce868d00488824 100644 --- a/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs +++ b/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs @@ -2,7 +2,7 @@ use ruff_python_parser::lexer::LexResult; use ruff_python_parser::Tok; use ruff_text_size::{TextRange, TextSize}; -use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix}; +use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix, FixAvailability, Violation}; use ruff_macros::{derive_message_formats, violation}; use ruff_source_file::Locator; @@ -44,7 +44,9 @@ pub struct BadQuotesInlineString { preferred_quote: Quote, } -impl AlwaysFixableViolation for BadQuotesInlineString { +impl Violation for BadQuotesInlineString { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + #[derive_message_formats] fn message(&self) -> String { let BadQuotesInlineString { preferred_quote } = self; @@ -54,11 +56,11 @@ impl AlwaysFixableViolation for BadQuotesInlineString { } } - fn fix_title(&self) -> String { + fn fix_title(&self) -> Option { let BadQuotesInlineString { preferred_quote } = self; match preferred_quote { - Quote::Double => "Replace single quotes with double quotes".to_string(), - Quote::Single => "Replace double quotes with single quotes".to_string(), + Quote::Double => Some("Replace single quotes with double quotes".to_string()), + Quote::Single => Some("Replace double quotes with single quotes".to_string()), } } } @@ -155,7 +157,9 @@ pub struct BadQuotesDocstring { preferred_quote: Quote, } -impl AlwaysFixableViolation for BadQuotesDocstring { +impl Violation for BadQuotesDocstring { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + #[derive_message_formats] fn message(&self) -> String { let BadQuotesDocstring { preferred_quote } = self; @@ -165,11 +169,11 @@ impl AlwaysFixableViolation for BadQuotesDocstring { } } - fn fix_title(&self) -> String { + fn fix_title(&self) -> Option { let BadQuotesDocstring { preferred_quote } = self; match preferred_quote { - Quote::Double => "Replace single quotes docstring with double quotes".to_string(), - Quote::Single => "Replace double quotes docstring with single quotes".to_string(), + Quote::Double => Some("Replace single quotes docstring with double quotes".to_string()), + Quote::Single => Some("Replace double quotes docstring with single quotes".to_string()), } } } @@ -203,6 +207,12 @@ struct Trivia<'a> { is_multiline: bool, } +impl Trivia<'_> { + fn has_empty_text(&self) -> bool { + self.raw_text == "\"\"" || self.raw_text == "''" + } +} + impl<'a> From<&'a str> for Trivia<'a> { fn from(value: &'a str) -> Self { // Remove any prefixes (e.g., remove `u` from `u"foo"`). @@ -231,12 +241,35 @@ impl<'a> From<&'a str> for Trivia<'a> { } } +fn text_ends_at_quote(locator: &Locator, range: TextRange, settings: &LinterSettings) -> bool { + let trivia_of_next_char: Trivia = locator + .slice(TextRange::new( + range.end(), + TextSize::new(range.end().to_u32() + 1), + )) + .into(); + trivia_of_next_char + .raw_text + .contains(good_docstring(settings.flake8_quotes.docstring_quotes)) +} + /// Q002 fn docstring(locator: &Locator, range: TextRange, settings: &LinterSettings) -> Option { let quotes_settings = &settings.flake8_quotes; let text = locator.slice(range); let trivia: Trivia = text.into(); + if trivia.has_empty_text() && text_ends_at_quote(locator, range, settings) { + // Cannot fix. Fix would result in a one-sided multi-line docstring, + // which would introduce an error. + let diagnostic = Diagnostic::new( + BadQuotesDocstring { + preferred_quote: quotes_settings.multiline_quotes, + }, + range, + ); + return Some(diagnostic); + } if trivia .raw_text @@ -344,6 +377,18 @@ fn strings( // If we're not using the preferred type, only allow use to avoid escapes. && !relax_quote { + if trivia.has_empty_text() && text_ends_at_quote(locator, *range, settings) { + // Cannot fix. Fix would result in a one-sided multi-line docstring, + // which would introduce an error. + let diagnostic = Diagnostic::new( + BadQuotesInlineString { + preferred_quote: quotes_settings.inline_quotes, + }, + *range, + ); + diagnostics.push(diagnostic); + continue; + } let mut diagnostic = Diagnostic::new( BadQuotesInlineString { preferred_quote: quotes_settings.inline_quotes, diff --git a/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_module_singleline_var_1.py.snap.new b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_module_singleline_var_1.py.snap.new new file mode 100644 index 00000000000000..db82003de32f24 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_module_singleline_var_1.py.snap.new @@ -0,0 +1,28 @@ +--- +source: crates/ruff_linter/src/rules/flake8_quotes/mod.rs +assertion_line: 151 +--- +docstring_singles_mixed_quotes_module_singleline_var_1.py:1:1: Q002 Double quote docstring found but single quotes preferred + | +1 | ''"Start with empty string" ' and lint docstring safely' + | ^^ Q002 +2 | +3 | def foo(): + | + = help: Replace double quotes docstring with single quotes + +docstring_singles_mixed_quotes_module_singleline_var_1.py:5:1: Q001 [*] Double quote multiline found but single quotes preferred + | +3 | def foo(): +4 | pass +5 | """ this is not a docstring """ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q001 + | + = help: Replace double multiline quotes with single quotes + +ℹ Safe fix +2 2 | +3 3 | def foo(): +4 4 | pass +5 |-""" this is not a docstring """ + 5 |+''' this is not a docstring ''' diff --git a/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_module_singleline_var_2.py.snap.new b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_module_singleline_var_2.py.snap.new new file mode 100644 index 00000000000000..08c1169d539f78 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_module_singleline_var_2.py.snap.new @@ -0,0 +1,35 @@ +--- +source: crates/ruff_linter/src/rules/flake8_quotes/mod.rs +assertion_line: 151 +--- +docstring_singles_mixed_quotes_module_singleline_var_2.py:1:1: Q002 [*] Single quote docstring found but double quotes preferred + | +1 | 'Do not'" start with empty string" ' and lint docstring safely' + | ^^^^^^^^ Q002 +2 | +3 | def foo(): + | + = help: Replace single quotes docstring with double quotes + +ℹ Safe fix +1 |-'Do not'" start with empty string" ' and lint docstring safely' + 1 |+"Do not"" start with empty string" ' and lint docstring safely' +2 2 | +3 3 | def foo(): +4 4 | pass + +docstring_singles_mixed_quotes_module_singleline_var_2.py:5:1: Q001 [*] Double quote multiline found but single quotes preferred + | +3 | def foo(): +4 | pass +5 | """ this is not a docstring """ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q001 + | + = help: Replace double multiline quotes with single quotes + +ℹ Safe fix +2 2 | +3 3 | def foo(): +4 4 | pass +5 |-""" this is not a docstring """ + 5 |+''' this is not a docstring ''' diff --git a/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_singles_over_docstring_doubles_mixed_quotes_module_singleline_var_1.py.snap.new b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_singles_over_docstring_doubles_mixed_quotes_module_singleline_var_1.py.snap.new new file mode 100644 index 00000000000000..a941a1a9bc4e09 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_singles_over_docstring_doubles_mixed_quotes_module_singleline_var_1.py.snap.new @@ -0,0 +1,12 @@ +--- +source: crates/ruff_linter/src/rules/flake8_quotes/mod.rs +assertion_line: 186 +--- +docstring_doubles_mixed_quotes_module_singleline_var_1.py:1:1: Q002 Single quote docstring found but double quotes preferred + | +1 | ""'Start with empty string' ' and lint docstring safely' + | ^^ Q002 +2 | +3 | def foo(): + | + = help: Replace single quotes docstring with double quotes diff --git a/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_singles_over_docstring_doubles_mixed_quotes_module_singleline_var_2.py.snap.new b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_singles_over_docstring_doubles_mixed_quotes_module_singleline_var_2.py.snap.new new file mode 100644 index 00000000000000..8993a4b7248bba --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_singles_over_docstring_doubles_mixed_quotes_module_singleline_var_2.py.snap.new @@ -0,0 +1,19 @@ +--- +source: crates/ruff_linter/src/rules/flake8_quotes/mod.rs +assertion_line: 186 +--- +docstring_doubles_mixed_quotes_module_singleline_var_2.py:1:1: Q002 [*] Double quote docstring found but single quotes preferred + | +1 | "Do not"' start with empty string' ' and lint docstring safely' + | ^^^^^^^^ Q002 +2 | +3 | def foo(): + | + = help: Replace double quotes docstring with single quotes + +ℹ Safe fix +1 |-"Do not"' start with empty string' ' and lint docstring safely' + 1 |+'Do not'' start with empty string' ' and lint docstring safely' +2 2 | +3 3 | def foo(): +4 4 | pass