diff --git a/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC502_google.py b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC502_google.py index f9e7f7a89f..614e38215c 100644 --- a/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC502_google.py +++ b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC502_google.py @@ -81,3 +81,16 @@ def calculate_speed(distance: float, time: float) -> float: except TypeError: print("Not a number? Shame on you!") raise + +# DOC502 regression for Sphinx directive after Raises (issue #18959) +def foo(): + """First line. + + Raises: + ValueError: + some text + + .. versionadded:: 0.7.0 + The ``init_kwargs`` argument. + """ + raise ValueError diff --git a/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs b/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs index 8db3c53808..91ae6d0e22 100644 --- a/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs +++ b/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs @@ -476,13 +476,45 @@ fn parse_entries(content: &str, style: Option) -> Vec Vec> { let mut entries: Vec = Vec::new(); - for potential in content.lines() { - let Some(colon_idx) = potential.find(':') else { - continue; - }; - let entry = potential[..colon_idx].trim(); - entries.push(QualifiedName::user_defined(entry)); + + // Determine the indentation of the entries from the first non-empty line. + // Google-style entries are indented relative to the "Raises:" header, e.g.: + // " ValueError: explanation". + let lines = content.lines(); + let mut expected_indent: Option<&str> = None; + + for raw in lines { + let line = raw.trim_end_matches('\r'); + + // Stop if we encounter an unindented line or a Sphinx directive starting with ".. ". + if !line.trim().is_empty() { + // Compute indentation of current line + let indent_len = line.len() - line.trim_start().len(); + let indent = &line[..indent_len]; + + // If this looks like a Sphinx directive or any unindented content, the section ends + if indent_len == 0 || line.trim_start().starts_with(".. ") { + break; + } + + // Establish expected indentation based on the first valid entry line + if expected_indent.is_none() { + expected_indent = Some(indent); + } else if Some(indent) != expected_indent { + // Different indentation likely starts a new sub-block; stop collecting + break; + } + + // Parse only lines that contain a colon and where the token before the colon is non-empty + if let Some(colon_idx) = line.find(':') { + let entry = line[..colon_idx].trim(); + if !entry.is_empty() { + entries.push(QualifiedName::user_defined(entry)); + } + } + } } + entries }