Avoid removing newlines between docstring headers and rST blocks (#11609)

Given:

```python
def func():
    """
    Example:

    .. code-block:: python

        import foo
    """
```

Removing the newline after the `Example:` header breaks Sphinx
rendering.

See: https://github.com/astral-sh/ruff/issues/11577
This commit is contained in:
Charlie Marsh 2024-05-30 13:29:20 -04:00 committed by GitHub
parent b0a751012e
commit 3aa7e35a4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 254 additions and 26 deletions

View file

@ -0,0 +1,67 @@
def func():
"""
Example:
.. code-block:: python
import foo
"""
def func():
"""
Example:
.. code-block:: python
import foo
"""
def func():
"""
Example:
.. code-block:: python
import foo
"""
def func():
"""
Example
-------
.. code-block:: python
import foo
"""
def func():
"""
Example
-------
.. code-block:: python
import foo
"""
def func():
"""
Example
-------
.. code-block:: python
import foo
"""

View file

@ -46,6 +46,7 @@ mod tests {
#[test_case(Rule::BlankLineBeforeClass, Path::new("D.py"))] #[test_case(Rule::BlankLineBeforeClass, Path::new("D.py"))]
#[test_case(Rule::NoBlankLineBeforeFunction, Path::new("D.py"))] #[test_case(Rule::NoBlankLineBeforeFunction, Path::new("D.py"))]
#[test_case(Rule::BlankLinesBetweenHeaderAndContent, Path::new("sections.py"))] #[test_case(Rule::BlankLinesBetweenHeaderAndContent, Path::new("sections.py"))]
#[test_case(Rule::BlankLinesBetweenHeaderAndContent, Path::new("sphinx.py"))]
#[test_case(Rule::OverIndentation, Path::new("D.py"))] #[test_case(Rule::OverIndentation, Path::new("D.py"))]
#[test_case(Rule::OverIndentation, Path::new("D208.py"))] #[test_case(Rule::OverIndentation, Path::new("D208.py"))]
#[test_case(Rule::NoSignature, Path::new("D.py"))] #[test_case(Rule::NoSignature, Path::new("D.py"))]

View file

@ -1393,14 +1393,14 @@ fn blanks_and_section_underline(
docstring: &Docstring, docstring: &Docstring,
context: &SectionContext, context: &SectionContext,
) { ) {
let mut blank_lines_after_header = 0; let mut num_blank_lines_after_header = 0u32;
let mut blank_lines_end = context.following_range().start(); let mut blank_lines_end = context.following_range().start();
let mut following_lines = context.following_lines().peekable(); let mut following_lines = context.following_lines().peekable();
while let Some(line) = following_lines.peek() { while let Some(line) = following_lines.peek() {
if line.trim().is_empty() { if line.trim().is_empty() {
blank_lines_end = line.full_end(); blank_lines_end = line.full_end();
blank_lines_after_header += 1; num_blank_lines_after_header += 1;
following_lines.next(); following_lines.next();
} else { } else {
break; break;
@ -1409,7 +1409,7 @@ fn blanks_and_section_underline(
if let Some(non_blank_line) = following_lines.next() { if let Some(non_blank_line) = following_lines.next() {
if let Some(dashed_line) = find_underline(&non_blank_line, '-') { if let Some(dashed_line) = find_underline(&non_blank_line, '-') {
if blank_lines_after_header > 0 { if num_blank_lines_after_header > 0 {
if checker.enabled(Rule::SectionUnderlineAfterName) { if checker.enabled(Rule::SectionUnderlineAfterName) {
let mut diagnostic = Diagnostic::new( let mut diagnostic = Diagnostic::new(
SectionUnderlineAfterName { SectionUnderlineAfterName {
@ -1475,10 +1475,12 @@ fn blanks_and_section_underline(
if let Some(line_after_dashes) = following_lines.next() { if let Some(line_after_dashes) = following_lines.next() {
if line_after_dashes.trim().is_empty() { if line_after_dashes.trim().is_empty() {
let mut num_blank_lines_dashes_end = 1u32;
let mut blank_lines_after_dashes_end = line_after_dashes.full_end(); let mut blank_lines_after_dashes_end = line_after_dashes.full_end();
while let Some(line) = following_lines.peek() { while let Some(line) = following_lines.peek() {
if line.trim().is_empty() { if line.trim().is_empty() {
blank_lines_after_dashes_end = line.full_end(); blank_lines_after_dashes_end = line.full_end();
num_blank_lines_dashes_end += 1;
following_lines.next(); following_lines.next();
} else { } else {
break; break;
@ -1495,6 +1497,44 @@ fn blanks_and_section_underline(
)); ));
} }
} else if checker.enabled(Rule::BlankLinesBetweenHeaderAndContent) { } else if checker.enabled(Rule::BlankLinesBetweenHeaderAndContent) {
// If the section is followed by exactly one line, and then a
// reStructuredText directive, the blank lines should be preserved, as in:
//
// ```python
// """
// Example
// -------
//
// .. code-block:: python
//
// import foo
// """
// ```
//
// Otherwise, documentation generators will not recognize the directive.
let is_sphinx = checker
.locator()
.line(blank_lines_after_dashes_end)
.trim_start()
.starts_with(".. ");
if is_sphinx {
if num_blank_lines_dashes_end > 1 {
let mut diagnostic = Diagnostic::new(
BlankLinesBetweenHeaderAndContent {
name: context.section_name().to_string(),
},
context.section_name_range(),
);
// Preserve a single blank line between the header and content.
diagnostic.set_fix(Fix::safe_edit(Edit::replacement(
checker.stylist().line_ending().to_string(),
line_after_dashes.start(),
blank_lines_after_dashes_end,
)));
checker.diagnostics.push(diagnostic);
}
} else {
let mut diagnostic = Diagnostic::new( let mut diagnostic = Diagnostic::new(
BlankLinesBetweenHeaderAndContent { BlankLinesBetweenHeaderAndContent {
name: context.section_name().to_string(), name: context.section_name().to_string(),
@ -1509,6 +1549,7 @@ fn blanks_and_section_underline(
checker.diagnostics.push(diagnostic); checker.diagnostics.push(diagnostic);
} }
} }
}
} else { } else {
if checker.enabled(Rule::EmptyDocstringSection) { if checker.enabled(Rule::EmptyDocstringSection) {
checker.diagnostics.push(Diagnostic::new( checker.diagnostics.push(Diagnostic::new(
@ -1560,21 +1601,62 @@ fn blanks_and_section_underline(
checker.diagnostics.push(diagnostic); checker.diagnostics.push(diagnostic);
} }
} }
if blank_lines_after_header > 0 { if num_blank_lines_after_header > 0 {
if checker.enabled(Rule::BlankLinesBetweenHeaderAndContent) { if checker.enabled(Rule::BlankLinesBetweenHeaderAndContent) {
// If the section is followed by exactly one line, and then a
// reStructuredText directive, the blank lines should be preserved, as in:
//
// ```python
// """
// Example:
//
// .. code-block:: python
//
// import foo
// """
// ```
//
// Otherwise, documentation generators will not recognize the directive.
let is_sphinx = checker
.locator()
.line(blank_lines_end)
.trim_start()
.starts_with(".. ");
if is_sphinx {
if num_blank_lines_after_header > 1 {
let mut diagnostic = Diagnostic::new( let mut diagnostic = Diagnostic::new(
BlankLinesBetweenHeaderAndContent { BlankLinesBetweenHeaderAndContent {
name: context.section_name().to_string(), name: context.section_name().to_string(),
}, },
context.section_name_range(), context.section_name_range(),
); );
let range = TextRange::new(context.following_range().start(), blank_lines_end);
// Preserve a single blank line between the header and content.
diagnostic.set_fix(Fix::safe_edit(Edit::replacement(
checker.stylist().line_ending().to_string(),
context.following_range().start(),
blank_lines_end,
)));
checker.diagnostics.push(diagnostic);
}
} else {
let mut diagnostic = Diagnostic::new(
BlankLinesBetweenHeaderAndContent {
name: context.section_name().to_string(),
},
context.section_name_range(),
);
let range =
TextRange::new(context.following_range().start(), blank_lines_end);
// Delete any blank lines between the header and content. // Delete any blank lines between the header and content.
diagnostic.set_fix(Fix::safe_edit(Edit::range_deletion(range))); diagnostic.set_fix(Fix::safe_edit(Edit::range_deletion(range)));
checker.diagnostics.push(diagnostic); checker.diagnostics.push(diagnostic);
} }
} }
} }
}
} else { } else {
// Nothing but blank lines after the section header. // Nothing but blank lines after the section header.
if checker.enabled(Rule::DashedUnderlineAfterSection) { if checker.enabled(Rule::DashedUnderlineAfterSection) {

View file

@ -0,0 +1,78 @@
---
source: crates/ruff_linter/src/rules/pydocstyle/mod.rs
---
sphinx.py:13:5: D412 [*] No blank lines allowed between a section header and its content ("Example")
|
11 | def func():
12 | """
13 | Example:
| ^^^^^^^ D412
|
= help: Remove blank line(s)
Safe fix
12 12 | """
13 13 | Example:
14 14 |
15 |-
16 15 | .. code-block:: python
17 16 |
18 17 | import foo
sphinx.py:24:5: D412 [*] No blank lines allowed between a section header and its content ("Example")
|
22 | def func():
23 | """
24 | Example:
| ^^^^^^^ D412
|
= help: Remove blank line(s)
Safe fix
23 23 | """
24 24 | Example:
25 25 |
26 |-
27 |-
28 26 | .. code-block:: python
29 27 |
30 28 | import foo
sphinx.py:47:5: D412 [*] No blank lines allowed between a section header and its content ("Example")
|
45 | def func():
46 | """
47 | Example
| ^^^^^^^ D412
48 | -------
|
= help: Remove blank line(s)
Safe fix
47 47 | Example
48 48 | -------
49 49 |
50 |-
51 50 | .. code-block:: python
52 51 |
53 52 | import foo
sphinx.py:59:5: D412 [*] No blank lines allowed between a section header and its content ("Example")
|
57 | def func():
58 | """
59 | Example
| ^^^^^^^ D412
60 | -------
|
= help: Remove blank line(s)
Safe fix
59 59 | Example
60 60 | -------
61 61 |
62 |-
63 |-
64 62 | .. code-block:: python
65 63 |
66 64 | import foo