diff --git a/crates/ruff_linter/resources/test/fixtures/pydocstyle/sphinx_directive.py b/crates/ruff_linter/resources/test/fixtures/pydocstyle/sphinx_directive.py new file mode 100644 index 0000000000000..4a79396323fed --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pydocstyle/sphinx_directive.py @@ -0,0 +1,78 @@ +"""A module-level docstring with a Sphinx directive containing section-like content. + +.. code-block:: yaml + + references: + - ref: Bibliographic citation in your favorite format. + refType: open literature + +This is more text after the directive. +""" + + +def func(): + """A function-level docstring with a Sphinx directive. + + Examples: + This is an example. + + .. code-block:: python + + returns = "not a section" + notes = "also not a section" + + Returns: + None + """ + + +def func2(): + """A function-level docstring with single-colon directive (invalid RST but still common). + + .. code-block: yaml + + references: + - ref: Some reference. + + More text. + """ + + +def func3(): + """A function-level docstring with nested directives. + + .. note:: + + .. code-block:: python + + warnings = "not a section" + + Returns: + None + """ + + +def func4(): + """A function-level docstring where a real section follows a directive. + + .. code-block:: python + + example = "code" + + Notes: + This IS a real section and should still be detected. + """ +# Regression test for nested directive state. +def func5(): + """A nested directive whose outer body contains section-like content. + + .. note:: + + .. code-block:: yaml + + references: + - ref: Some reference. + + references: + Still part of the note body, not a section. + """ diff --git a/crates/ruff_linter/src/docstrings/sections.rs b/crates/ruff_linter/src/docstrings/sections.rs index a4151c67aa056..85c447410633b 100644 --- a/crates/ruff_linter/src/docstrings/sections.rs +++ b/crates/ruff_linter/src/docstrings/sections.rs @@ -155,11 +155,31 @@ impl<'a> SectionContexts<'a> { // Skip the first line, which is the summary. let mut previous_line = lines.next(); + // Track only the outermost RST directive. Nested directives remain in its body + // until a non-blank line dedents to the outermost indentation. + // See: https://github.com/astral-sh/ruff/issues/23562 + let mut directive_indent = None; + while let Some(line) = lines.next() { - if let Some(section_kind) = suspected_as_section(&line, style) { - let indent = leading_space(&line); - let indent_size = indent.text_len(); + let indent = leading_space(&line); + let indent_size = indent.text_len(); + + if let Some(active_indent) = directive_indent + && (line.trim().is_empty() || indent_size > active_indent) + { + previous_line = Some(line); + continue; + } + + directive_indent = None; + if line.trim_start().starts_with(".. ") { + directive_indent = Some(indent_size); + previous_line = Some(line); + continue; + } + + if let Some(section_kind) = suspected_as_section(&line, style) { let section_name = leading_words(&line); let section_name_size = section_name.text_len(); diff --git a/crates/ruff_linter/src/rules/pydocstyle/mod.rs b/crates/ruff_linter/src/rules/pydocstyle/mod.rs index e94315db80b1c..e3490be53234f 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/mod.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/mod.rs @@ -85,6 +85,12 @@ mod tests { #[test_case(Rule::MissingSectionNameColon, Path::new("D.py"))] #[test_case(Rule::OverindentedSection, Path::new("sections.py"))] #[test_case(Rule::OverindentedSection, Path::new("D214_module.py"))] + #[test_case(Rule::OverindentedSection, Path::new("sphinx_directive.py"))] + #[test_case(Rule::NonCapitalizedSectionName, Path::new("sphinx_directive.py"))] + #[test_case( + Rule::MissingBlankLineAfterLastSection, + Path::new("sphinx_directive.py") + )] #[test_case(Rule::OverindentedSectionUnderline, Path::new("D215.py"))] #[test_case(Rule::MissingSectionUnderlineAfterName, Path::new("sections.py"))] #[test_case(Rule::MismatchedSectionUnderlineLength, Path::new("sections.py"))] diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D214_sphinx_directive.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D214_sphinx_directive.py.snap new file mode 100644 index 0000000000000..724d6e7d20ae3 --- /dev/null +++ b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D214_sphinx_directive.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/pydocstyle/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D405_sphinx_directive.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D405_sphinx_directive.py.snap new file mode 100644 index 0000000000000..724d6e7d20ae3 --- /dev/null +++ b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D405_sphinx_directive.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/pydocstyle/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D413_sphinx_directive.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D413_sphinx_directive.py.snap new file mode 100644 index 0000000000000..7c9ba10d96f03 --- /dev/null +++ b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D413_sphinx_directive.py.snap @@ -0,0 +1,53 @@ +--- +source: crates/ruff_linter/src/rules/pydocstyle/mod.rs +--- +D413 [*] Missing blank line after last section ("Returns") + --> sphinx_directive.py:24:5 + | +22 | notes = "also not a section" +23 | +24 | Returns: + | ^^^^^^^ +25 | None +26 | """ + | +help: Add blank line after "Returns" + | +25 | None +26 + +27 | """ + | + +D413 [*] Missing blank line after last section ("Returns") + --> sphinx_directive.py:50:5 + | +48 | warnings = "not a section" +49 | +50 | Returns: + | ^^^^^^^ +51 | None +52 | """ + | +help: Add blank line after "Returns" + | +51 | None +52 + +53 | """ + | + +D413 [*] Missing blank line after last section ("Notes") + --> sphinx_directive.py:62:5 + | +60 | example = "code" +61 | +62 | Notes: + | ^^^^^ +63 | This IS a real section and should still be detected. +64 | """ + | +help: Add blank line after "Notes" + | +63 | This IS a real section and should still be detected. +64 + +65 | """ + |