Skip to content

Commit babf8d7

Browse files
authored
Fix D204 when there's a semicolon after a docstring (#7174)
## Summary Another statement on the same line as the docstring would previous make the D204 (newline after docstring) fix fail: ```python class StatementOnSameLineAsDocstring: "After this docstring there's another statement on the same line separated by a semicolon." ;priorities=1 def sort_services(self): pass ``` The fix handles this case manually: ```python class StatementOnSameLineAsDocstring: "After this docstring there's another statement on the same line separated by a semicolon." priorities=1 def sort_services(self): pass ``` Fixes #7088 ## Test Plan Added a new `D` test case
1 parent 878813f commit babf8d7

8 files changed

+224
-11
lines changed

crates/ruff/resources/test/fixtures/pydocstyle/D.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,3 +644,17 @@ def same_line(): """This is a docstring on the same line"""
644644
def single_line_docstring_with_an_escaped_backslash():
645645
"\
646646
"
647+
648+
class StatementOnSameLineAsDocstring:
649+
"After this docstring there's another statement on the same line separated by a semicolon." ; priorities=1
650+
def sort_services(self):
651+
pass
652+
653+
class StatementOnSameLineAsDocstring:
654+
"After this docstring there's another statement on the same line separated by a semicolon."; priorities=1
655+
656+
657+
class CommentAfterDocstring:
658+
"After this docstring there's a comment." # priorities=1
659+
def sort_services(self):
660+
pass

crates/ruff/src/rules/pydocstyle/rules/blank_before_after_class.rs

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use ruff_diagnostics::{AlwaysAutofixableViolation, Diagnostic, Edit, Fix};
22
use ruff_macros::{derive_message_formats, violation};
3-
use ruff_python_trivia::PythonWhitespace;
4-
use ruff_source_file::{UniversalNewlineIterator, UniversalNewlines};
3+
use ruff_python_trivia::{indentation_at_offset, PythonWhitespace};
4+
use ruff_source_file::{Line, UniversalNewlineIterator};
55
use ruff_text_size::Ranged;
66
use ruff_text_size::{TextLen, TextRange};
77

@@ -216,25 +216,61 @@ pub(crate) fn blank_before_after_class(checker: &mut Checker, docstring: &Docstr
216216
}
217217

218218
if checker.enabled(Rule::OneBlankLineAfterClass) {
219-
let after_range = TextRange::new(docstring.end(), class.end());
220-
221-
let after = checker.locator().slice(after_range);
219+
let class_after_docstring_range = TextRange::new(docstring.end(), class.end());
220+
let class_after_docstring = checker.locator().slice(class_after_docstring_range);
221+
let mut lines = UniversalNewlineIterator::with_offset(
222+
class_after_docstring,
223+
class_after_docstring_range.start(),
224+
);
222225

223-
let all_blank_after = after.universal_newlines().skip(1).all(|line| {
226+
// If the class is empty except for comments, we don't need to insert a newline between
227+
// docstring and no content
228+
let all_blank_after = lines.clone().all(|line| {
224229
line.trim_whitespace().is_empty() || line.trim_whitespace_start().starts_with('#')
225230
});
226231
if all_blank_after {
227232
return;
228233
}
229234

230-
let mut lines = UniversalNewlineIterator::with_offset(after, after_range.start());
235+
let first_line = lines.next();
236+
let mut replacement_start = first_line.as_ref().map(Line::start).unwrap_or_default();
237+
238+
// Edge case: There is trailing end-of-line content after the docstring, either a statement
239+
// separated by a semicolon or a comment.
240+
if let Some(first_line) = &first_line {
241+
let trailing = first_line.as_str().trim_whitespace_start();
242+
if let Some(next_statement) = trailing.strip_prefix(';') {
243+
let indentation = indentation_at_offset(docstring.start(), checker.locator())
244+
.expect("Own line docstring must have indentation");
245+
let mut diagnostic = Diagnostic::new(OneBlankLineAfterClass, docstring.range());
246+
if checker.patch(diagnostic.kind.rule()) {
247+
let line_ending = checker.stylist().line_ending().as_str();
248+
// We have to trim the whitespace twice, once before the semicolon above and
249+
// once after the semicolon here, or we get invalid indents:
250+
// ```rust
251+
// class Priority:
252+
// """Has priorities""" ; priorities=1
253+
// ```
254+
let next_statement = next_statement.trim_whitespace_start();
255+
diagnostic.set_fix(Fix::automatic(Edit::replacement(
256+
line_ending.to_string() + line_ending + indentation + next_statement,
257+
replacement_start,
258+
first_line.end(),
259+
)));
260+
}
261+
checker.diagnostics.push(diagnostic);
262+
return;
263+
} else if trailing.starts_with('#') {
264+
// Keep the end-of-line comment, start counting empty lines after it
265+
replacement_start = first_line.end();
266+
}
267+
}
231268

232-
let first_line_start = lines.next().map(|l| l.start()).unwrap_or_default();
233269
let mut blank_lines_after = 0usize;
234-
let mut blank_lines_end = docstring.end();
270+
let mut blank_lines_end = first_line.as_ref().map_or(docstring.end(), Line::end);
235271

236272
for line in lines {
237-
if line.trim().is_empty() {
273+
if line.trim_whitespace().is_empty() {
238274
blank_lines_end = line.end();
239275
blank_lines_after += 1;
240276
} else {
@@ -248,7 +284,7 @@ pub(crate) fn blank_before_after_class(checker: &mut Checker, docstring: &Docstr
248284
// Insert a blank line before the class (replacing any existing lines).
249285
diagnostic.set_fix(Fix::automatic(Edit::replacement(
250286
checker.stylist().line_ending().to_string(),
251-
first_line_start,
287+
replacement_start,
252288
blank_lines_end,
253289
)));
254290
}

crates/ruff/src/rules/pydocstyle/snapshots/ruff__rules__pydocstyle__tests__D102_D.py.snap

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,22 @@ D.py:68:9: D102 Missing docstring in public method
2525
69 | pass
2626
|
2727

28+
D.py:650:9: D102 Missing docstring in public method
29+
|
30+
648 | class StatementOnSameLineAsDocstring:
31+
649 | "After this docstring there's another statement on the same line separated by a semicolon." ; priorities=1
32+
650 | def sort_services(self):
33+
| ^^^^^^^^^^^^^ D102
34+
651 | pass
35+
|
36+
37+
D.py:659:9: D102 Missing docstring in public method
38+
|
39+
657 | class CommentAfterDocstring:
40+
658 | "After this docstring there's a comment." # priorities=1
41+
659 | def sort_services(self):
42+
| ^^^^^^^^^^^^^ D102
43+
660 | pass
44+
|
45+
2846

crates/ruff/src/rules/pydocstyle/snapshots/ruff__rules__pydocstyle__tests__D200_D.py.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ D.py:645:5: D200 One-line docstring should fit on one line
104104
| _____^
105105
646 | | "
106106
| |_____^ D200
107+
647 |
108+
648 | class StatementOnSameLineAsDocstring:
107109
|
108110
= help: Reformat to one line
109111

crates/ruff/src/rules/pydocstyle/snapshots/ruff__rules__pydocstyle__tests__D203_D.py.snap

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,59 @@ D.py:526:5: D203 [*] 1 blank line required before class docstring
6363
527 528 |
6464
528 529 | Parameters
6565

66+
D.py:649:5: D203 [*] 1 blank line required before class docstring
67+
|
68+
648 | class StatementOnSameLineAsDocstring:
69+
649 | "After this docstring there's another statement on the same line separated by a semicolon." ; priorities=1
70+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ D203
71+
650 | def sort_services(self):
72+
651 | pass
73+
|
74+
= help: Insert 1 blank line before class docstring
75+
76+
Fix
77+
646 646 | "
78+
647 647 |
79+
648 648 | class StatementOnSameLineAsDocstring:
80+
649 |+
81+
649 650 | "After this docstring there's another statement on the same line separated by a semicolon." ; priorities=1
82+
650 651 | def sort_services(self):
83+
651 652 | pass
84+
85+
D.py:654:5: D203 [*] 1 blank line required before class docstring
86+
|
87+
653 | class StatementOnSameLineAsDocstring:
88+
654 | "After this docstring there's another statement on the same line separated by a semicolon."; priorities=1
89+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ D203
90+
|
91+
= help: Insert 1 blank line before class docstring
92+
93+
Fix
94+
651 651 | pass
95+
652 652 |
96+
653 653 | class StatementOnSameLineAsDocstring:
97+
654 |+
98+
654 655 | "After this docstring there's another statement on the same line separated by a semicolon."; priorities=1
99+
655 656 |
100+
656 657 |
101+
102+
D.py:658:5: D203 [*] 1 blank line required before class docstring
103+
|
104+
657 | class CommentAfterDocstring:
105+
658 | "After this docstring there's a comment." # priorities=1
106+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ D203
107+
659 | def sort_services(self):
108+
660 | pass
109+
|
110+
= help: Insert 1 blank line before class docstring
111+
112+
Fix
113+
655 655 |
114+
656 656 |
115+
657 657 | class CommentAfterDocstring:
116+
658 |+
117+
658 659 | "After this docstring there's a comment." # priorities=1
118+
659 660 | def sort_services(self):
119+
660 661 | pass
120+
66121

crates/ruff/src/rules/pydocstyle/snapshots/ruff__rules__pydocstyle__tests__D204_D.py.snap

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,64 @@ D.py:192:5: D204 [*] 1 blank line required after class docstring
3838
194 195 |
3939
195 196 |
4040

41+
D.py:649:5: D204 [*] 1 blank line required after class docstring
42+
|
43+
648 | class StatementOnSameLineAsDocstring:
44+
649 | "After this docstring there's another statement on the same line separated by a semicolon." ; priorities=1
45+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ D204
46+
650 | def sort_services(self):
47+
651 | pass
48+
|
49+
= help: Insert 1 blank line after class docstring
50+
51+
Fix
52+
646 646 | "
53+
647 647 |
54+
648 648 | class StatementOnSameLineAsDocstring:
55+
649 |- "After this docstring there's another statement on the same line separated by a semicolon." ; priorities=1
56+
649 |+ "After this docstring there's another statement on the same line separated by a semicolon."
57+
650 |+
58+
651 |+ priorities=1
59+
650 652 | def sort_services(self):
60+
651 653 | pass
61+
652 654 |
62+
63+
D.py:654:5: D204 [*] 1 blank line required after class docstring
64+
|
65+
653 | class StatementOnSameLineAsDocstring:
66+
654 | "After this docstring there's another statement on the same line separated by a semicolon."; priorities=1
67+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ D204
68+
|
69+
= help: Insert 1 blank line after class docstring
70+
71+
Fix
72+
651 651 | pass
73+
652 652 |
74+
653 653 | class StatementOnSameLineAsDocstring:
75+
654 |- "After this docstring there's another statement on the same line separated by a semicolon."; priorities=1
76+
654 |+ "After this docstring there's another statement on the same line separated by a semicolon."
77+
655 |+
78+
656 |+ priorities=1
79+
655 657 |
80+
656 658 |
81+
657 659 | class CommentAfterDocstring:
82+
83+
D.py:658:5: D204 [*] 1 blank line required after class docstring
84+
|
85+
657 | class CommentAfterDocstring:
86+
658 | "After this docstring there's a comment." # priorities=1
87+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ D204
88+
659 | def sort_services(self):
89+
660 | pass
90+
|
91+
= help: Insert 1 blank line after class docstring
92+
93+
Fix
94+
656 656 |
95+
657 657 | class CommentAfterDocstring:
96+
658 658 | "After this docstring there's a comment." # priorities=1
97+
659 |+
98+
659 660 | def sort_services(self):
99+
660 661 | pass
100+
41101

crates/ruff/src/rules/pydocstyle/snapshots/ruff__rules__pydocstyle__tests__D300_D.py.snap

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,33 @@ D.py:645:5: D300 Use triple double quotes `"""`
4848
| _____^
4949
646 | | "
5050
| |_____^ D300
51+
647 |
52+
648 | class StatementOnSameLineAsDocstring:
53+
|
54+
55+
D.py:649:5: D300 Use triple double quotes `"""`
56+
|
57+
648 | class StatementOnSameLineAsDocstring:
58+
649 | "After this docstring there's another statement on the same line separated by a semicolon." ; priorities=1
59+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ D300
60+
650 | def sort_services(self):
61+
651 | pass
62+
|
63+
64+
D.py:654:5: D300 Use triple double quotes `"""`
65+
|
66+
653 | class StatementOnSameLineAsDocstring:
67+
654 | "After this docstring there's another statement on the same line separated by a semicolon."; priorities=1
68+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ D300
69+
|
70+
71+
D.py:658:5: D300 Use triple double quotes `"""`
72+
|
73+
657 | class CommentAfterDocstring:
74+
658 | "After this docstring there's a comment." # priorities=1
75+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ D300
76+
659 | def sort_services(self):
77+
660 | pass
5178
|
5279

5380

crates/ruff_source_file/src/newlines.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ impl UniversalNewlines for str {
3232
/// assert_eq!(lines.next_back(), Some(Line::new("\r\n", TextSize::from(8))));
3333
/// assert_eq!(lines.next(), None);
3434
/// ```
35+
#[derive(Clone)]
3536
pub struct UniversalNewlineIterator<'a> {
3637
text: &'a str,
3738
offset: TextSize,

0 commit comments

Comments
 (0)