diff --git a/crates/ruff_linter/resources/test/fixtures/pydocstyle/D301.py b/crates/ruff_linter/resources/test/fixtures/pydocstyle/D301.py index 5950a0ce5374b..886967caaf1ed 100644 --- a/crates/ruff_linter/resources/test/fixtures/pydocstyle/D301.py +++ b/crates/ruff_linter/resources/test/fixtures/pydocstyle/D301.py @@ -35,3 +35,67 @@ def make_unique_pod_id(pod_id: str) -> str | None: def shouldnt_add_raw_here2(): u"Sum\\mary." + + +def shouldnt_add_raw_for_double_quote_docstring_contains_docstring(): + """ + This docstring contains another double-quote docstring. + + def foo(): + \"\"\"Foo.\"\"\" + """ + + +def shouldnt_add_raw_for_double_quote_docstring_contains_docstring2(): + """ + This docstring contains another double-quote docstring. + + def bar(): + \"""Bar.\""" + + More content here. + """ + + +def shouldnt_add_raw_for_single_quote_docstring_contains_docstring(): + ''' + This docstring contains another single-quote docstring. + + def foo(): + \'\'\'Foo.\'\'\' + + More content here. + ''' + + +def shouldnt_add_raw_for_single_quote_docstring_contains_docstring2(): + ''' + This docstring contains another single-quote docstring. + + def bar(): + \'''Bar.\''' + + More content here. + ''' + +def shouldnt_add_raw_for_docstring_contains_escaped_double_triple_quotes(): + """ + Escaped triple quote \""" or \"\"\". + """ + +def shouldnt_add_raw_for_docstring_contains_escaped_single_triple_quotes(): + ''' + Escaped triple quote \''' or \'\'\'. + ''' + + +def should_add_raw_for_single_double_quote_escape(): + """ + This is single quote escape \". + """ + + +def should_add_raw_for_single_single_quote_escape(): + ''' + This is single quote escape \'. + ''' diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/backslashes.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/backslashes.rs index 97403748ce4c5..46cf52b63deea 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/backslashes.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/backslashes.rs @@ -1,5 +1,3 @@ -use memchr::memchr_iter; - use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation}; use ruff_macros::{derive_message_formats, violation}; use ruff_text_size::Ranged; @@ -69,20 +67,47 @@ pub(crate) fn backslashes(checker: &mut Checker, docstring: &Docstring) { // Docstring contains at least one backslash. let body = docstring.body(); let bytes = body.as_bytes(); - if memchr_iter(b'\\', bytes).any(|position| { - let escaped_char = bytes.get(position.saturating_add(1)); - // Allow continuations (backslashes followed by newlines) and Unicode escapes. - !matches!(escaped_char, Some(b'\r' | b'\n' | b'u' | b'U' | b'N')) - }) { - let mut diagnostic = Diagnostic::new(EscapeSequenceInDocstring, docstring.range()); + let mut offset = 0; + while let Some(position) = memchr::memchr(b'\\', &bytes[offset..]) { + if position + offset + 1 >= body.len() { + break; + } + + let after_escape = &body[position + offset + 1..]; + + // End of Docstring. + let Some(escaped_char) = &after_escape.chars().next() else { + break; + }; - if !docstring.leading_quote().contains(['u', 'U']) { - diagnostic.set_fix(Fix::unsafe_edit(Edit::range_replacement( - "r".to_owned() + docstring.contents, - docstring.range(), - ))); + if matches!(escaped_char, '"' | '\'') { + // If the next three characters are equal to """, it indicates an escaped docstring pattern. + if after_escape.starts_with("\"\"\"") || after_escape.starts_with("\'\'\'") { + offset += position + 3; + continue; + } + // If the next three characters are equal to "\"\", it indicates an escaped docstring pattern. + if after_escape.starts_with("\"\\\"\\\"") || after_escape.starts_with("\'\\\'\\\'") { + offset += position + 5; + continue; + } } - checker.diagnostics.push(diagnostic); + offset += position + escaped_char.len_utf8(); + + // Only allow continuations (backslashes followed by newlines) and Unicode escapes. + if !matches!(*escaped_char, '\r' | '\n' | 'u' | 'U' | 'N') { + let mut diagnostic = Diagnostic::new(EscapeSequenceInDocstring, docstring.range()); + + if !docstring.leading_quote().contains(['u', 'U']) { + diagnostic.set_fix(Fix::unsafe_edit(Edit::range_replacement( + "r".to_owned() + docstring.contents, + docstring.range(), + ))); + } + + checker.diagnostics.push(diagnostic); + break; + } } } diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D301_D301.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D301_D301.py.snap index efd485a81cd2b..2d729186933d7 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D301_D301.py.snap +++ b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D301_D301.py.snap @@ -25,4 +25,43 @@ D301.py:37:5: D301 Use `r"""` if any backslashes in a docstring | = help: Add `r` prefix +D301.py:93:5: D301 [*] Use `r"""` if any backslashes in a docstring + | +92 | def should_add_raw_for_single_double_quote_escape(): +93 | """ + | _____^ +94 | | This is single quote escape \". +95 | | """ + | |_______^ D301 + | + = help: Add `r` prefix +ℹ Unsafe fix +90 90 | +91 91 | +92 92 | def should_add_raw_for_single_double_quote_escape(): +93 |- """ + 93 |+ r""" +94 94 | This is single quote escape \". +95 95 | """ +96 96 | + +D301.py:99:5: D301 [*] Use `r"""` if any backslashes in a docstring + | + 98 | def should_add_raw_for_single_single_quote_escape(): + 99 | ''' + | _____^ +100 | | This is single quote escape \'. +101 | | ''' + | |_______^ D301 + | + = help: Add `r` prefix + +ℹ Unsafe fix +96 96 | +97 97 | +98 98 | def should_add_raw_for_single_single_quote_escape(): +99 |- ''' + 99 |+ r''' +100 100 | This is single quote escape \'. +101 101 | '''