Disambiguate argument descriptors from section headers (#9427)

## Summary

Given a docstring like:

```python
def func(x: int, args: tuple[int]):
    """Toggle the gizmo.

    Args:
        x: Some argument.
        args: Some other arguments.
    """
```

We were considering the `args:` descriptor to be an indented docstring
section header (since `Args:`) is a valid header name. This led to very
confusing diagnostics.

This PR makes the parsing a bit more lax in this case, such that if we
see a nested header that's more deeply indented than the preceding
header, and the preceding section allows sub-items (like `Args:`), we
avoid treating the nested item as a section header.

Closes https://github.com/astral-sh/ruff/issues/9426.
This commit is contained in:
Charlie Marsh 2024-01-07 22:41:00 -05:00 committed by GitHub
parent d5a439cbd3
commit 04afdf177b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 200 additions and 8 deletions

View file

@ -153,13 +153,17 @@ impl<'a> SectionContexts<'a> {
while let Some(line) = lines.next() {
if let Some(section_kind) = suspected_as_section(&line, style) {
let indent = leading_space(&line);
let section_name = leading_words(&line);
let indent_size = indent.text_len();
let section_name_range = TextRange::at(indent.text_len(), section_name.text_len());
let section_name = leading_words(&line);
let section_name_size = section_name.text_len();
if is_docstring_section(
&line,
section_name_range,
indent_size,
section_name_size,
section_kind,
last.as_ref(),
previous_line.as_ref(),
lines.peek(),
) {
@ -170,7 +174,8 @@ impl<'a> SectionContexts<'a> {
last = Some(SectionContextData {
kind: section_kind,
name_range: section_name_range + line.start(),
indent_size: indent.text_len(),
name_range: TextRange::at(line.start() + indent_size, section_name_size),
range: TextRange::empty(line.start()),
summary_full_end: line.full_end(),
});
@ -204,8 +209,8 @@ impl<'a> SectionContexts<'a> {
}
impl<'a> IntoIterator for &'a SectionContexts<'a> {
type IntoIter = SectionContextsIter<'a>;
type Item = SectionContext<'a>;
type IntoIter = SectionContextsIter<'a>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
@ -257,6 +262,9 @@ impl ExactSizeIterator for SectionContextsIter<'_> {}
struct SectionContextData {
kind: SectionKind,
/// The size of the indentation of the section name.
indent_size: TextSize,
/// Range of the section name, relative to the [`Docstring::body`]
name_range: TextRange,
@ -401,12 +409,15 @@ fn suspected_as_section(line: &str, style: SectionStyle) -> Option<SectionKind>
/// Check if the suspected context is really a section header.
fn is_docstring_section(
line: &Line,
section_name_range: TextRange,
indent_size: TextSize,
section_name_size: TextSize,
section_kind: SectionKind,
previous_section: Option<&SectionContextData>,
previous_line: Option<&Line>,
next_line: Option<&Line>,
) -> bool {
// Determine whether the current line looks like a section header, e.g., "Args:".
let section_name_suffix = line[usize::from(section_name_range.end())..].trim();
let section_name_suffix = line[usize::from(indent_size + section_name_size)..].trim();
let this_looks_like_a_section_name =
section_name_suffix == ":" || section_name_suffix.is_empty();
if !this_looks_like_a_section_name {
@ -439,5 +450,25 @@ fn is_docstring_section(
return false;
}
// Determine if this is a sub-section within another section, like `args` in:
// ```python
// def func(args: tuple[int]):
// """Toggle the gizmo.
//
// Args:
// args: The arguments to the function.
// """
// ```
// However, if the header is an _exact_ match (like `Returns:`, as opposed to `returns:`), then
// continue to treat it as a section header.
if let Some(previous_section) = previous_section {
if previous_section.indent_size < indent_size {
let verbatim = &line[TextRange::at(indent_size, section_name_size)];
if section_kind.as_str() != verbatim {
return false;
}
}
}
true
}