diff --git a/README.md b/README.md index 32853bf7dc..fdee9ff189 100644 --- a/README.md +++ b/README.md @@ -216,12 +216,13 @@ variables.) ruff also implements some of the most popular Flake8 plugins natively, including: +- [`pydocstyle`](https://pypi.org/project/pydocstyle/) +- [`flake8-docstrings`](https://pypi.org/project/flake8-docstrings/) - [`flake8-builtins`](https://pypi.org/project/flake8-builtins/) - [`flake8-super`](https://pypi.org/project/flake8-super/) - [`flake8-print`](https://pypi.org/project/flake8-print/) - [`flake8-comprehensions`](https://pypi.org/project/flake8-comprehensions/) - [`flake8-bugbear`](https://pypi.org/project/flake8-bugbear/) (3/32) -- [`flake8-docstrings`](https://pypi.org/project/flake8-docstrings/) (41/48) - [`pyupgrade`](https://pypi.org/project/pyupgrade/) (8/34) Beyond rule-set parity, ruff suffers from the following limitations vis-à-vis Flake8: @@ -327,6 +328,9 @@ The 🛠 emoji indicates that a rule is automatically fixable by the `--fix` com | D203 | OneBlankLineBeforeClass | 1 blank line required before class docstring | | | | D204 | OneBlankLineAfterClass | 1 blank line required after class docstring | | | | D205 | NoBlankLineAfterSummary | 1 blank line required between summary line and description | | | +| D206 | IndentWithSpaces | Docstring should be indented with spaces, not tabs | | | +| D207 | NoUnderIndentation | Docstring is under-indented | | | +| D208 | NoOverIndentation | Docstring is over-indented | | | | D209 | NewLineAfterLastParagraph | Multi-line docstring closing quotes should be on a separate line | | | | D210 | NoSurroundingWhitespace | No whitespaces allowed surrounding docstring text | | | | D211 | NoBlankLineBeforeClass | No blank lines allowed before class docstring | | | diff --git a/src/check_ast.rs b/src/check_ast.rs index 39879eca68..ae6b762781 100644 --- a/src/check_ast.rs +++ b/src/check_ast.rs @@ -1969,6 +1969,12 @@ impl<'a> Checker<'a> { if self.settings.enabled.contains(&CheckCode::D205) { docstring_plugins::blank_after_summary(self, &docstring); } + if self.settings.enabled.contains(&CheckCode::D206) + || self.settings.enabled.contains(&CheckCode::D207) + || self.settings.enabled.contains(&CheckCode::D208) + { + docstring_plugins::indent(self, &docstring); + } if self.settings.enabled.contains(&CheckCode::D209) { docstring_plugins::newline_after_last_paragraph(self, &docstring); } diff --git a/src/checks.rs b/src/checks.rs index 0bdf8d4d4c..e6208041db 100644 --- a/src/checks.rs +++ b/src/checks.rs @@ -170,6 +170,9 @@ pub enum CheckCode { D203, D204, D205, + D206, + D207, + D208, D209, D210, D211, @@ -309,6 +312,7 @@ pub enum CheckKind { EndsInPunctuation, FirstLineCapitalized, FitsOnOneLine, + IndentWithSpaces, MagicMethod, MultiLineSummaryFirstLine, MultiLineSummarySecondLine, @@ -319,9 +323,11 @@ pub enum CheckKind { NoBlankLineBeforeClass(usize), NoBlankLineBeforeFunction(usize), NoBlankLinesBetweenHeaderAndContent(String), + NoOverIndentation, NoSignature, NoSurroundingWhitespace, NoThisPrefix, + NoUnderIndentation, NonEmpty, NonEmptySection(String), OneBlankLineAfterClass(usize), @@ -473,6 +479,9 @@ impl CheckCode { CheckCode::D203 => CheckKind::OneBlankLineBeforeClass(0), CheckCode::D204 => CheckKind::OneBlankLineAfterClass(0), CheckCode::D205 => CheckKind::NoBlankLineAfterSummary, + CheckCode::D206 => CheckKind::IndentWithSpaces, + CheckCode::D207 => CheckKind::NoUnderIndentation, + CheckCode::D208 => CheckKind::NoOverIndentation, CheckCode::D209 => CheckKind::NewLineAfterLastParagraph, CheckCode::D210 => CheckKind::NoSurroundingWhitespace, CheckCode::D211 => CheckKind::NoBlankLineBeforeClass(1), @@ -609,6 +618,7 @@ impl CheckKind { CheckKind::EndsInPunctuation => &CheckCode::D415, CheckKind::FirstLineCapitalized => &CheckCode::D403, CheckKind::FitsOnOneLine => &CheckCode::D200, + CheckKind::IndentWithSpaces => &CheckCode::D206, CheckKind::MagicMethod => &CheckCode::D105, CheckKind::MultiLineSummaryFirstLine => &CheckCode::D212, CheckKind::MultiLineSummarySecondLine => &CheckCode::D213, @@ -619,9 +629,11 @@ impl CheckKind { CheckKind::NoBlankLineBeforeClass(_) => &CheckCode::D211, CheckKind::NoBlankLineBeforeFunction(_) => &CheckCode::D201, CheckKind::NoBlankLinesBetweenHeaderAndContent(_) => &CheckCode::D412, + CheckKind::NoOverIndentation => &CheckCode::D208, CheckKind::NoSignature => &CheckCode::D402, CheckKind::NoSurroundingWhitespace => &CheckCode::D210, CheckKind::NoThisPrefix => &CheckCode::D404, + CheckKind::NoUnderIndentation => &CheckCode::D207, CheckKind::NonEmpty => &CheckCode::D419, CheckKind::NonEmptySection(_) => &CheckCode::D414, CheckKind::OneBlankLineAfterClass(_) => &CheckCode::D204, @@ -1009,6 +1021,11 @@ impl CheckKind { format!("Missing argument descriptions in the docstring: {names}") } } + CheckKind::IndentWithSpaces => { + "Docstring should be indented with spaces, not tabs".to_string() + } + CheckKind::NoUnderIndentation => "Docstring is under-indented".to_string(), + CheckKind::NoOverIndentation => "Docstring is over-indented".to_string(), // Meta CheckKind::UnusedNOQA(codes) => match codes { None => "Unused `noqa` directive".to_string(), diff --git a/src/docstrings/docstring_plugins.rs b/src/docstrings/docstring_plugins.rs index e58611662a..4b856951f7 100644 --- a/src/docstrings/docstring_plugins.rs +++ b/src/docstrings/docstring_plugins.rs @@ -9,6 +9,7 @@ use crate::check_ast::Checker; use crate::checks::{Check, CheckCode, CheckKind}; use crate::docstrings::google::check_google_section; use crate::docstrings::helpers; +use crate::docstrings::helpers::{indentation, leading_space}; use crate::docstrings::numpy::check_numpy_section; use crate::docstrings::sections::section_contexts; use crate::docstrings::styles::SectionStyle; @@ -303,6 +304,89 @@ pub fn blank_after_summary(checker: &mut Checker, definition: &Definition) { } } +/// D206, D207, D208 +pub fn indent(checker: &mut Checker, definition: &Definition) { + if let Some(docstring) = definition.docstring { + if let ExprKind::Constant { + value: Constant::Str(string), + .. + } = &docstring.node + { + let lines: Vec<&str> = string.lines().collect(); + if lines.len() <= 1 { + return; + } + + let mut has_seen_tab = false; + let mut has_seen_over_indent = false; + let mut has_seen_under_indent = false; + + let docstring_indent = indentation(checker, docstring).to_string(); + if !has_seen_tab { + if docstring_indent.contains('\t') { + if checker.settings.enabled.contains(&CheckCode::D206) { + checker.add_check(Check::new( + CheckKind::IndentWithSpaces, + helpers::range_for(docstring), + )); + } + has_seen_tab = true; + } + } + + for i in 0..lines.len() { + // First lines and continuations doesn't need any indentation. + if i == 0 || lines[i - 1].ends_with('\\') { + continue; + } + + // Omit empty lines, except for the last line, which is non-empty by way of + // containing the closing quotation marks. + if i < lines.len() - 1 && lines[i].trim().is_empty() { + continue; + } + + let line_indent = leading_space(lines[i]); + if !has_seen_tab { + if line_indent.contains('\t') { + if checker.settings.enabled.contains(&CheckCode::D206) { + checker.add_check(Check::new( + CheckKind::IndentWithSpaces, + helpers::range_for(docstring), + )); + } + has_seen_tab = true; + } + } + + if !has_seen_over_indent { + if line_indent.len() > docstring_indent.len() { + if checker.settings.enabled.contains(&CheckCode::D208) { + checker.add_check(Check::new( + CheckKind::NoOverIndentation, + helpers::range_for(docstring), + )); + } + has_seen_over_indent = true; + } + } + + if !has_seen_under_indent { + if line_indent.len() < docstring_indent.len() { + if checker.settings.enabled.contains(&CheckCode::D207) { + checker.add_check(Check::new( + CheckKind::NoUnderIndentation, + helpers::range_for(docstring), + )); + } + has_seen_under_indent = true; + } + } + } + } + } +} + /// D209 pub fn newline_after_last_paragraph(checker: &mut Checker, definition: &Definition) { if let Some(docstring) = definition.docstring { diff --git a/src/linter.rs b/src/linter.rs index 09479e995f..3f42065d66 100644 --- a/src/linter.rs +++ b/src/linter.rs @@ -273,6 +273,9 @@ mod tests { #[test_case(CheckCode::D203, Path::new("D.py"); "D203")] #[test_case(CheckCode::D204, Path::new("D.py"); "D204")] #[test_case(CheckCode::D205, Path::new("D.py"); "D205")] + #[test_case(CheckCode::D206, Path::new("D.py"); "D206")] + #[test_case(CheckCode::D207, Path::new("D.py"); "D207")] + #[test_case(CheckCode::D208, Path::new("D.py"); "D208")] #[test_case(CheckCode::D209, Path::new("D.py"); "D209")] #[test_case(CheckCode::D210, Path::new("D.py"); "D210")] #[test_case(CheckCode::D211, Path::new("D.py"); "D211")] diff --git a/src/snapshots/ruff__linter__tests__D206_D.py.snap b/src/snapshots/ruff__linter__tests__D206_D.py.snap new file mode 100644 index 0000000000..60c615f917 --- /dev/null +++ b/src/snapshots/ruff__linter__tests__D206_D.py.snap @@ -0,0 +1,6 @@ +--- +source: src/linter.rs +expression: checks +--- +[] + diff --git a/src/snapshots/ruff__linter__tests__D207_D.py.snap b/src/snapshots/ruff__linter__tests__D207_D.py.snap new file mode 100644 index 0000000000..9a23249ce3 --- /dev/null +++ b/src/snapshots/ruff__linter__tests__D207_D.py.snap @@ -0,0 +1,29 @@ +--- +source: src/linter.rs +expression: checks +--- +- kind: NoUnderIndentation + location: + row: 225 + column: 5 + end_location: + row: 229 + column: 8 + fix: ~ +- kind: NoUnderIndentation + location: + row: 235 + column: 5 + end_location: + row: 239 + column: 4 + fix: ~ +- kind: NoUnderIndentation + location: + row: 433 + column: 37 + end_location: + row: 436 + column: 8 + fix: ~ + diff --git a/src/snapshots/ruff__linter__tests__D208_D.py.snap b/src/snapshots/ruff__linter__tests__D208_D.py.snap new file mode 100644 index 0000000000..73ddcd2484 --- /dev/null +++ b/src/snapshots/ruff__linter__tests__D208_D.py.snap @@ -0,0 +1,29 @@ +--- +source: src/linter.rs +expression: checks +--- +- kind: NoOverIndentation + location: + row: 245 + column: 5 + end_location: + row: 249 + column: 8 + fix: ~ +- kind: NoOverIndentation + location: + row: 255 + column: 5 + end_location: + row: 259 + column: 12 + fix: ~ +- kind: NoOverIndentation + location: + row: 265 + column: 5 + end_location: + row: 269 + column: 8 + fix: ~ +