From 5e3097ae59421128ebd546e4e8becfe1b6ad6e8f Mon Sep 17 00:00:00 2001 From: bxff <51504045+bxff@users.noreply.github.com> Date: Sun, 1 Mar 2026 00:50:34 +0530 Subject: [PATCH 1/4] [`pydocstyle`] Skip section detection inside RST directive bodies (`D214`, `D405`, `D413`) Closes #23562 Content inside reStructuredText directives (e.g., `.. code-block:: yaml`) was incorrectly identified as docstring section headers. For example, `references:` inside a code-block would trigger D405 (capitalization), D214 (over-indentation), and D413 (missing blank line). This adds RST directive body tracking to `from_docstring` in the section parser. When a line starting with `.. ` is detected, all subsequent lines indented deeper than the directive are skipped from section detection. Real sections after directives continue to be detected correctly. --- .../fixtures/pydocstyle/sphinx_directive.py | 64 +++++++++++++++++++ crates/ruff_linter/src/docstrings/sections.rs | 31 +++++++++ .../ruff_linter/src/rules/pydocstyle/mod.rs | 6 ++ ...tyle__tests__D214_sphinx_directive.py.snap | 4 ++ ...tyle__tests__D405_sphinx_directive.py.snap | 4 ++ ...tyle__tests__D413_sphinx_directive.py.snap | 57 +++++++++++++++++ 6 files changed, 166 insertions(+) create mode 100644 crates/ruff_linter/resources/test/fixtures/pydocstyle/sphinx_directive.py create mode 100644 crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D214_sphinx_directive.py.snap create mode 100644 crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D405_sphinx_directive.py.snap create mode 100644 crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D413_sphinx_directive.py.snap 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..9c123f5714494 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pydocstyle/sphinx_directive.py @@ -0,0 +1,64 @@ +"""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. + """ diff --git a/crates/ruff_linter/src/docstrings/sections.rs b/crates/ruff_linter/src/docstrings/sections.rs index a4151c67aa056..4f20786cf1d93 100644 --- a/crates/ruff_linter/src/docstrings/sections.rs +++ b/crates/ruff_linter/src/docstrings/sections.rs @@ -155,7 +155,38 @@ impl<'a> SectionContexts<'a> { // Skip the first line, which is the summary. let mut previous_line = lines.next(); + // Track the indentation of an active RST directive (e.g., `.. code-block:: python`). + // Lines indented deeper than this are the directive body and should be skipped + // from section detection. See: https://github.com/astral-sh/ruff/issues/23562 + let mut directive_indent: Option = None; + while let Some(line) = lines.next() { + let trimmed = line.trim_start(); + + // Detect RST directive start (lines like `.. code-block:: yaml`) + if trimmed.starts_with(".. ") { + directive_indent = Some(leading_space(&line).text_len()); + previous_line = Some(line); + continue; + } + + // If inside a directive body, skip indented lines (the directive body) + if let Some(dir_indent) = directive_indent { + if line.trim().is_empty() { + // Blank lines don't end directive bodies + previous_line = Some(line); + continue; + } + let current_indent = leading_space(&line).text_len(); + if current_indent > dir_indent { + // Still inside the directive body + previous_line = Some(line); + continue; + } + // Indentation returned to or above directive level — we've exited + directive_indent = None; + } + if let Some(section_kind) = suspected_as_section(&line, style) { let indent = leading_space(&line); let indent_size = indent.text_len(); diff --git a/crates/ruff_linter/src/rules/pydocstyle/mod.rs b/crates/ruff_linter/src/rules/pydocstyle/mod.rs index 948f770f0b336..ae9cc8e951a48 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..e7bc104a63e0b --- /dev/null +++ b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D413_sphinx_directive.py.snap @@ -0,0 +1,57 @@ +--- +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" +23 | +24 | Returns: +25 | None +26 + +27 | """ +28 | +29 | + +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" +49 | +50 | Returns: +51 | None +52 + +53 | """ +54 | +55 | + +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" +61 | +62 | Notes: +63 | This IS a real section and should still be detected. +64 + +65 | """ From 5b1436816683ad5a6e18c620c2f80265b2f009d9 Mon Sep 17 00:00:00 2001 From: bxff <51504045+bxff@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:40:30 +0530 Subject: [PATCH 2/4] fix(pydocstyle): track nested rst directives --- .../fixtures/pydocstyle/sphinx_directive.py | 14 ++++++ crates/ruff_linter/src/docstrings/sections.rs | 50 +++++++++++-------- ...tyle__tests__D413_sphinx_directive.py.snap | 2 + 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/pydocstyle/sphinx_directive.py b/crates/ruff_linter/resources/test/fixtures/pydocstyle/sphinx_directive.py index 9c123f5714494..4a79396323fed 100644 --- a/crates/ruff_linter/resources/test/fixtures/pydocstyle/sphinx_directive.py +++ b/crates/ruff_linter/resources/test/fixtures/pydocstyle/sphinx_directive.py @@ -62,3 +62,17 @@ def func4(): 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 4f20786cf1d93..043336e9211f7 100644 --- a/crates/ruff_linter/src/docstrings/sections.rs +++ b/crates/ruff_linter/src/docstrings/sections.rs @@ -155,36 +155,44 @@ impl<'a> SectionContexts<'a> { // Skip the first line, which is the summary. let mut previous_line = lines.next(); - // Track the indentation of an active RST directive (e.g., `.. code-block:: python`). - // Lines indented deeper than this are the directive body and should be skipped - // from section detection. See: https://github.com/astral-sh/ruff/issues/23562 - let mut directive_indent: Option = None; + // Track the indentation of active RST directives (e.g., `.. code-block:: python`). + // Lines indented deeper than the innermost active directive are the directive body + // and should be skipped from section detection. + // See: https://github.com/astral-sh/ruff/issues/23562 + let mut directive_indents: Vec = Vec::new(); while let Some(line) = lines.next() { let trimmed = line.trim_start(); - // Detect RST directive start (lines like `.. code-block:: yaml`) - if trimmed.starts_with(".. ") { - directive_indent = Some(leading_space(&line).text_len()); - previous_line = Some(line); - continue; - } - - // If inside a directive body, skip indented lines (the directive body) - if let Some(dir_indent) = directive_indent { - if line.trim().is_empty() { - // Blank lines don't end directive bodies + if line.trim().is_empty() { + // Blank lines don't end directive bodies. + if !directive_indents.is_empty() { previous_line = Some(line); continue; } + } else { let current_indent = leading_space(&line).text_len(); - if current_indent > dir_indent { - // Still inside the directive body - previous_line = Some(line); - continue; + + // Indentation returned to or above a directive level; we've exited that + // directive body, but may still be inside an outer directive body. + while directive_indents + .last() + .is_some_and(|directive_indent| current_indent <= *directive_indent) + { + directive_indents.pop(); } - // Indentation returned to or above directive level — we've exited - directive_indent = None; + } + + // Detect RST directive start (lines like `.. code-block:: yaml`). + if trimmed.starts_with(".. ") { + directive_indents.push(leading_space(&line).text_len()); + previous_line = Some(line); + continue; + } + + if !directive_indents.is_empty() { + previous_line = Some(line); + continue; } if let Some(section_kind) = suspected_as_section(&line, style) { 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 index e7bc104a63e0b..77e586c242b9f 100644 --- 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 @@ -55,3 +55,5 @@ help: Add blank line after "Notes" 63 | This IS a real section and should still be detected. 64 + 65 | """ +66 | # Regression test for nested directive state. +67 | def func5(): From b746d9a2c00ba3203bd04a1554d1a1b764f95885 Mon Sep 17 00:00:00 2001 From: bxff <51504045+bxff@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:47:31 +0530 Subject: [PATCH 3/4] test(pydocstyle): normalize sphinx directive snapshot --- ...tyle__tests__D413_sphinx_directive.py.snap | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 index 77e586c242b9f..83f37194a3356 100644 --- 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 @@ -12,13 +12,13 @@ D413 [*] Missing blank line after last section ("Returns") 26 | """ | help: Add blank line after "Returns" -23 | +23 | 24 | Returns: 25 | None -26 + +26 + 27 | """ -28 | -29 | +28 | +29 | D413 [*] Missing blank line after last section ("Returns") --> sphinx_directive.py:50:5 @@ -31,13 +31,13 @@ D413 [*] Missing blank line after last section ("Returns") 52 | """ | help: Add blank line after "Returns" -49 | +49 | 50 | Returns: 51 | None -52 + +52 + 53 | """ -54 | -55 | +54 | +55 | D413 [*] Missing blank line after last section ("Notes") --> sphinx_directive.py:62:5 @@ -50,10 +50,10 @@ D413 [*] Missing blank line after last section ("Notes") 64 | """ | help: Add blank line after "Notes" -61 | +61 | 62 | Notes: 63 | This IS a real section and should still be detected. -64 + +64 + 65 | """ 66 | # Regression test for nested directive state. 67 | def func5(): From 0707fce54b42ea3e10779ea41bb8463d76b8a3c9 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Tue, 28 Jul 2026 14:13:06 -0400 Subject: [PATCH 4/4] simplify --- crates/ruff_linter/src/docstrings/sections.rs | 43 ++++++------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/crates/ruff_linter/src/docstrings/sections.rs b/crates/ruff_linter/src/docstrings/sections.rs index 043336e9211f7..85c447410633b 100644 --- a/crates/ruff_linter/src/docstrings/sections.rs +++ b/crates/ruff_linter/src/docstrings/sections.rs @@ -155,50 +155,31 @@ impl<'a> SectionContexts<'a> { // Skip the first line, which is the summary. let mut previous_line = lines.next(); - // Track the indentation of active RST directives (e.g., `.. code-block:: python`). - // Lines indented deeper than the innermost active directive are the directive body - // and should be skipped from section detection. + // 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_indents: Vec = Vec::new(); + let mut directive_indent = None; while let Some(line) = lines.next() { - let trimmed = line.trim_start(); + let indent = leading_space(&line); + let indent_size = indent.text_len(); - if line.trim().is_empty() { - // Blank lines don't end directive bodies. - if !directive_indents.is_empty() { - previous_line = Some(line); - continue; - } - } else { - let current_indent = leading_space(&line).text_len(); - - // Indentation returned to or above a directive level; we've exited that - // directive body, but may still be inside an outer directive body. - while directive_indents - .last() - .is_some_and(|directive_indent| current_indent <= *directive_indent) - { - directive_indents.pop(); - } - } - - // Detect RST directive start (lines like `.. code-block:: yaml`). - if trimmed.starts_with(".. ") { - directive_indents.push(leading_space(&line).text_len()); + if let Some(active_indent) = directive_indent + && (line.trim().is_empty() || indent_size > active_indent) + { previous_line = Some(line); continue; } - if !directive_indents.is_empty() { + 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 indent = leading_space(&line); - let indent_size = indent.text_len(); - let section_name = leading_words(&line); let section_name_size = section_name.text_len();