diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs index ebd8fc312af41..dbe02ee66be8c 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs @@ -2,6 +2,7 @@ use itertools::Itertools; use once_cell::sync::Lazy; use regex::Regex; use rustc_hash::FxHashSet; +use std::ops::Add; use ruff_diagnostics::{AlwaysFixableViolation, Violation}; use ruff_diagnostics::{Diagnostic, Edit, Fix}; @@ -10,8 +11,8 @@ use ruff_python_ast::docstrings::{clean_space, leading_space}; use ruff_python_ast::identifier::Identifier; use ruff_python_ast::ParameterWithDefault; use ruff_python_semantic::analyze::visibility::is_staticmethod; -use ruff_python_trivia::{textwrap::dedent, PythonWhitespace}; -use ruff_source_file::NewlineWithTrailingNewline; +use ruff_python_trivia::{textwrap::dedent, Cursor}; +use ruff_source_file::{Line, NewlineWithTrailingNewline}; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use crate::checkers::ast::Checker; @@ -1377,50 +1378,41 @@ fn blanks_and_section_underline( } if let Some(non_blank_line) = following_lines.next() { - let dash_line_found = is_dashed_underline(&non_blank_line); - - if dash_line_found { + if let Some(dashed_line) = find_underline(&non_blank_line, '-') { if blank_lines_after_header > 0 { if checker.enabled(Rule::SectionUnderlineAfterName) { let mut diagnostic = Diagnostic::new( SectionUnderlineAfterName { name: context.section_name().to_string(), }, - context.section_name_range(), + dashed_line, ); - let range = TextRange::new(context.following_range().start(), blank_lines_end); + // Delete any blank lines between the header and the underline. - diagnostic.set_fix(Fix::safe_edit(Edit::range_deletion(range))); + diagnostic.set_fix(Fix::safe_edit(Edit::deletion( + context.following_range().start(), + blank_lines_end, + ))); + checker.diagnostics.push(diagnostic); } } - if non_blank_line - .trim() - .chars() - .filter(|char| *char == '-') - .count() - != context.section_name().len() - { + if dashed_line.len().to_usize() != context.section_name().len() { if checker.enabled(Rule::SectionUnderlineMatchesSectionLength) { let mut diagnostic = Diagnostic::new( SectionUnderlineMatchesSectionLength { name: context.section_name().to_string(), }, - context.section_name_range(), + dashed_line, ); + // Replace the existing underline with a line of the appropriate length. - let content = format!( - "{}{}{}", - clean_space(docstring.indentation), + diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement( "-".repeat(context.section_name().len()), - checker.stylist().line_ending().as_str() - ); - diagnostic.set_fix(Fix::safe_edit(Edit::replacement( - content, - blank_lines_end, - non_blank_line.full_end(), + dashed_line, ))); + checker.diagnostics.push(diagnostic); } } @@ -1432,8 +1424,9 @@ fn blanks_and_section_underline( SectionUnderlineNotOverIndented { name: context.section_name().to_string(), }, - context.section_name_range(), + dashed_line, ); + // Replace the existing indentation with whitespace of the appropriate length. let range = TextRange::at( blank_lines_end, @@ -1445,6 +1438,7 @@ fn blanks_and_section_underline( } else { Edit::range_replacement(contents, range) })); + checker.diagnostics.push(diagnostic); } } @@ -1496,42 +1490,45 @@ fn blanks_and_section_underline( } } } else { - let equal_line_found = non_blank_line - .chars() - .all(|char| char.is_whitespace() || char == '='); - if checker.enabled(Rule::DashedUnderlineAfterSection) { - let mut diagnostic = Diagnostic::new( - DashedUnderlineAfterSection { - name: context.section_name().to_string(), - }, - context.section_name_range(), - ); - // Add a dashed line (of the appropriate length) under the section header. - let content = format!( - "{}{}{}", - checker.stylist().line_ending().as_str(), - clean_space(docstring.indentation), - "-".repeat(context.section_name().len()), - ); - if equal_line_found - && non_blank_line.trim_whitespace().len() == context.section_name().len() - { + if let Some(equal_line) = find_underline(&non_blank_line, '=') { + let mut diagnostic = Diagnostic::new( + DashedUnderlineAfterSection { + name: context.section_name().to_string(), + }, + equal_line, + ); + // If an existing underline is an equal sign line of the appropriate length, // replace it with a dashed line. - diagnostic.set_fix(Fix::safe_edit(Edit::replacement( - content, - context.summary_range().end(), - non_blank_line.end(), + diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement( + "-".repeat(context.section_name().len()), + equal_line, ))); + + checker.diagnostics.push(diagnostic); } else { - // Otherwise, insert a dashed line after the section header. + let mut diagnostic = Diagnostic::new( + DashedUnderlineAfterSection { + name: context.section_name().to_string(), + }, + context.section_name_range(), + ); + + // Add a dashed line (of the appropriate length) under the section header. + let content = format!( + "{}{}{}", + checker.stylist().line_ending().as_str(), + clean_space(docstring.indentation), + "-".repeat(context.section_name().len()), + ); diagnostic.set_fix(Fix::safe_edit(Edit::insertion( content, context.summary_range().end(), ))); + + checker.diagnostics.push(diagnostic); } - checker.diagnostics.push(diagnostic); } if blank_lines_after_header > 0 { if checker.enabled(Rule::BlankLinesBetweenHeaderAndContent) { @@ -1548,9 +1545,8 @@ fn blanks_and_section_underline( } } } - } - // Nothing but blank lines after the section header. - else { + } else { + // Nothing but blank lines after the section header. if checker.enabled(Rule::DashedUnderlineAfterSection) { let mut diagnostic = Diagnostic::new( DashedUnderlineAfterSection { @@ -1558,6 +1554,7 @@ fn blanks_and_section_underline( }, context.section_name_range(), ); + // Add a dashed line (of the appropriate length) under the section header. let content = format!( "{}{}{}", @@ -1565,11 +1562,11 @@ fn blanks_and_section_underline( clean_space(docstring.indentation), "-".repeat(context.section_name().len()), ); - diagnostic.set_fix(Fix::safe_edit(Edit::insertion( content, context.summary_range().end(), ))); + checker.diagnostics.push(diagnostic); } if checker.enabled(Rule::EmptyDocstringSection) { @@ -1804,10 +1801,11 @@ fn args_section(context: &SectionContext) -> FxHashSet { let leading_space = leading_space(first_line.as_str()); let relevant_lines = std::iter::once(first_line) .chain(following_lines) - .map(|l| l.as_str()) .filter(|line| { - line.is_empty() || (line.starts_with(leading_space) && !is_dashed_underline(line)) + line.is_empty() + || (line.starts_with(leading_space) && find_underline(line, '-').is_none()) }) + .map(|line| line.as_str()) .join("\n"); let args_content = dedent(&relevant_lines); @@ -1995,7 +1993,35 @@ fn parse_google_sections( } } -fn is_dashed_underline(line: &str) -> bool { - let trimmed_line = line.trim(); - !trimmed_line.is_empty() && trimmed_line.chars().all(|char| char == '-') +/// Returns the [`TextRange`] of the underline, if a line consists of only dashes. +fn find_underline(line: &Line, dash: char) -> Option { + let mut cursor = Cursor::new(line.as_str()); + + // Eat leading whitespace. + cursor.eat_while(char::is_whitespace); + + // Determine the start of the dashes. + let offset = cursor.token_len(); + + // Consume the dashes. + cursor.start_token(); + cursor.eat_while(|c| c == dash); + + // Determine the end of the dashes. + let len = cursor.token_len(); + + // If there are no dashes, return None. + if len == TextSize::new(0) { + return None; + } + + // Eat trailing whitespace. + cursor.eat_while(char::is_whitespace); + + // If there are any characters after the dashes, return None. + if !cursor.is_eof() { + return None; + } + + Some(TextRange::at(offset, len).add(line.start())) } diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D215_D215.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D215_D215.py.snap index 5593c0b70f2d9..b93074fda8c98 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D215_D215.py.snap +++ b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D215_D215.py.snap @@ -1,12 +1,12 @@ --- source: crates/ruff_linter/src/rules/pydocstyle/mod.rs --- -D215.py:2:1: D215 [*] Section underline is over-indented ("TODO") +D215.py:3:5: D215 [*] Section underline is over-indented ("TODO") | 1 | """ 2 | TODO: - | ^^^^ D215 3 | - + | ^ D215 4 | """ | = help: Remove over-indentation from "TODO" underline diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D215_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D215_sections.py.snap index e8440a2e20268..afbd747fbd769 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D215_sections.py.snap +++ b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D215_sections.py.snap @@ -1,13 +1,11 @@ --- source: crates/ruff_linter/src/rules/pydocstyle/mod.rs --- -sections.py:158:5: D215 [*] Section underline is over-indented ("Returns") +sections.py:159:9: D215 [*] Section underline is over-indented ("Returns") | -156 | """Toggle the gizmo. -157 | 158 | Returns - | ^^^^^^^ D215 159 | ------- + | ^^^^^^^ D215 160 | A value of some sort. | = help: Remove over-indentation from "Returns" underline @@ -22,13 +20,11 @@ sections.py:158:5: D215 [*] Section underline is over-indented ("Returns") 161 161 | 162 162 | """ -sections.py:172:5: D215 [*] Section underline is over-indented ("Returns") +sections.py:173:9: D215 [*] Section underline is over-indented ("Returns") | -170 | """Toggle the gizmo. -171 | 172 | Returns - | ^^^^^^^ D215 173 | ------- + | ^^^^^^^ D215 174 | """ | = help: Remove over-indentation from "Returns" underline diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D407_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D407_sections.py.snap index 4cc0216f92e74..61fe5b4c6da24 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D407_sections.py.snap +++ b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D407_sections.py.snap @@ -331,13 +331,11 @@ sections.py:503:9: D407 [*] Missing dashed underline after section ("Args") 505 506 | 506 507 | """ -sections.py:521:5: D407 [*] Missing dashed underline after section ("Parameters") +sections.py:522:5: D407 [*] Missing dashed underline after section ("Parameters") | -519 | """Equal length equals should be replaced with dashes. -520 | 521 | Parameters - | ^^^^^^^^^^ D407 522 | ========== + | ^^^^^^^^^^ D407 523 | """ | = help: Add dashed line under "Parameters" @@ -352,13 +350,11 @@ sections.py:521:5: D407 [*] Missing dashed underline after section ("Parameters" 524 524 | 525 525 | -sections.py:529:5: D407 [*] Missing dashed underline after section ("Parameters") +sections.py:530:5: D407 [*] Missing dashed underline after section ("Parameters") | -527 | """Here, the length of equals is not the same. -528 | 529 | Parameters - | ^^^^^^^^^^ D407 530 | =========== + | ^^^^^^^^^^^ D407 531 | """ | = help: Add dashed line under "Parameters" @@ -367,10 +363,11 @@ sections.py:529:5: D407 [*] Missing dashed underline after section ("Parameters" 527 527 | """Here, the length of equals is not the same. 528 528 | 529 529 | Parameters +530 |- =========== 530 |+ ---------- -530 531 | =========== -531 532 | """ -532 533 | +531 531 | """ +532 532 | +533 533 | sections.py:550:5: D407 [*] Missing dashed underline after section ("Args") | diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D408_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D408_sections.py.snap index bd1ec3be6f295..38bec4612b427 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D408_sections.py.snap +++ b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D408_sections.py.snap @@ -1,14 +1,13 @@ --- source: crates/ruff_linter/src/rules/pydocstyle/mod.rs --- -sections.py:96:5: D408 [*] Section underline should be in the line following the section's name ("Returns") +sections.py:98:5: D408 [*] Section underline should be in the line following the section's name ("Returns") | -94 | """Toggle the gizmo. -95 | 96 | Returns - | ^^^^^^^ D408 97 | 98 | ------- + | ^^^^^^^ D408 +99 | A value of some sort. | = help: Add underline to "Returns" diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D409_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D409_sections.py.snap index 2f13f07fd9749..1b59fad56deee 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D409_sections.py.snap +++ b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D409_sections.py.snap @@ -1,13 +1,11 @@ --- source: crates/ruff_linter/src/rules/pydocstyle/mod.rs --- -sections.py:110:5: D409 [*] Section underline should match the length of its name ("Returns") +sections.py:111:5: D409 [*] Section underline should match the length of its name ("Returns") | -108 | """Toggle the gizmo. -109 | 110 | Returns - | ^^^^^^^ D409 111 | -- + | ^^ D409 112 | A value of some sort. | = help: Adjust underline length to match "Returns" @@ -22,14 +20,13 @@ sections.py:110:5: D409 [*] Section underline should match the length of its nam 113 113 | 114 114 | """ -sections.py:224:5: D409 [*] Section underline should match the length of its name ("Returns") +sections.py:225:5: D409 [*] Section underline should match the length of its name ("Returns") | -222 | returns. -223 | 224 | Returns - | ^^^^^^^ D409 225 | ------ + | ^^^^^^ D409 226 | Many many wonderful things. +227 | Raises: | = help: Adjust underline length to match "Returns" @@ -43,14 +40,13 @@ sections.py:224:5: D409 [*] Section underline should match the length of its nam 227 227 | Raises: 228 228 | My attention. -sections.py:577:5: D409 [*] Section underline should match the length of its name ("Other Parameters") +sections.py:578:5: D409 [*] Section underline should match the length of its name ("Other Parameters") | -575 | A dictionary of string attributes -576 | 577 | Other Parameters - | ^^^^^^^^^^^^^^^^ D409 578 | ---------- + | ^^^^^^^^^^ D409 579 | other_parameters: +580 | A dictionary of string attributes | = help: Adjust underline length to match "Other Parameters"