diff --git a/src/check_ast.rs b/src/check_ast.rs index 59bd8c4c28..39879eca68 100644 --- a/src/check_ast.rs +++ b/src/check_ast.rs @@ -21,13 +21,13 @@ use crate::ast::visitor::{walk_excepthandler, Visitor}; use crate::ast::{checkers, helpers, operations, visitor}; use crate::autofix::{fixer, fixes}; use crate::checks::{Check, CheckCode, CheckKind}; -use crate::docstrings::docstring_checks; +use crate::docstrings::docstring_plugins; use crate::docstrings::types::{Definition, DefinitionKind, Documentable}; -use crate::plugins; use crate::python::builtins::{BUILTINS, MAGIC_GLOBALS}; use crate::python::future::ALL_FEATURE_NAMES; use crate::settings::{PythonVersion, Settings}; use crate::visibility::{module_visibility, transition_scope, Modifier, Visibility, VisibleScope}; +use crate::{docstrings, plugins}; pub const GLOBAL_SCOPE_INDEX: usize = 0; @@ -572,7 +572,7 @@ where let prev_visibile_scope = self.visible_scope.clone(); match &stmt.node { StmtKind::FunctionDef { body, .. } | StmtKind::AsyncFunctionDef { body, .. } => { - let definition = docstring_checks::extract( + let definition = docstrings::extraction::extract( &self.visible_scope, stmt, body, @@ -590,7 +590,7 @@ where )); } StmtKind::ClassDef { body, .. } => { - let definition = docstring_checks::extract( + let definition = docstrings::extraction::extract( &self.visible_scope, stmt, body, @@ -1683,7 +1683,7 @@ impl<'a> Checker<'a> { where 'b: 'a, { - let docstring = docstring_checks::docstring_from(python_ast); + let docstring = docstrings::extraction::docstring_from(python_ast); self.docstrings.push(( Definition { kind: if self.path.ends_with("__init__.py") { @@ -1946,60 +1946,60 @@ impl<'a> Checker<'a> { fn check_docstrings(&mut self) { while let Some((docstring, visibility)) = self.docstrings.pop() { - if !docstring_checks::not_empty(self, &docstring) { + if !docstring_plugins::not_empty(self, &docstring) { continue; } - if !docstring_checks::not_missing(self, &docstring, &visibility) { + if !docstring_plugins::not_missing(self, &docstring, &visibility) { continue; } if self.settings.enabled.contains(&CheckCode::D200) { - docstring_checks::one_liner(self, &docstring); + docstring_plugins::one_liner(self, &docstring); } if self.settings.enabled.contains(&CheckCode::D201) || self.settings.enabled.contains(&CheckCode::D202) { - docstring_checks::blank_before_after_function(self, &docstring); + docstring_plugins::blank_before_after_function(self, &docstring); } if self.settings.enabled.contains(&CheckCode::D203) || self.settings.enabled.contains(&CheckCode::D204) || self.settings.enabled.contains(&CheckCode::D211) { - docstring_checks::blank_before_after_class(self, &docstring); + docstring_plugins::blank_before_after_class(self, &docstring); } if self.settings.enabled.contains(&CheckCode::D205) { - docstring_checks::blank_after_summary(self, &docstring); + docstring_plugins::blank_after_summary(self, &docstring); } if self.settings.enabled.contains(&CheckCode::D209) { - docstring_checks::newline_after_last_paragraph(self, &docstring); + docstring_plugins::newline_after_last_paragraph(self, &docstring); } if self.settings.enabled.contains(&CheckCode::D210) { - docstring_checks::no_surrounding_whitespace(self, &docstring); + docstring_plugins::no_surrounding_whitespace(self, &docstring); } if self.settings.enabled.contains(&CheckCode::D212) || self.settings.enabled.contains(&CheckCode::D213) { - docstring_checks::multi_line_summary_start(self, &docstring); + docstring_plugins::multi_line_summary_start(self, &docstring); } if self.settings.enabled.contains(&CheckCode::D300) { - docstring_checks::triple_quotes(self, &docstring); + docstring_plugins::triple_quotes(self, &docstring); } if self.settings.enabled.contains(&CheckCode::D400) { - docstring_checks::ends_with_period(self, &docstring); + docstring_plugins::ends_with_period(self, &docstring); } if self.settings.enabled.contains(&CheckCode::D402) { - docstring_checks::no_signature(self, &docstring); + docstring_plugins::no_signature(self, &docstring); } if self.settings.enabled.contains(&CheckCode::D403) { - docstring_checks::capitalized(self, &docstring); + docstring_plugins::capitalized(self, &docstring); } if self.settings.enabled.contains(&CheckCode::D404) { - docstring_checks::starts_with_this(self, &docstring); + docstring_plugins::starts_with_this(self, &docstring); } if self.settings.enabled.contains(&CheckCode::D415) { - docstring_checks::ends_with_punctuation(self, &docstring); + docstring_plugins::ends_with_punctuation(self, &docstring); } if self.settings.enabled.contains(&CheckCode::D418) { - docstring_checks::if_needed(self, &docstring); + docstring_plugins::if_needed(self, &docstring); } if self.settings.enabled.contains(&CheckCode::D212) || self.settings.enabled.contains(&CheckCode::D214) @@ -2007,7 +2007,6 @@ impl<'a> Checker<'a> { || self.settings.enabled.contains(&CheckCode::D405) || self.settings.enabled.contains(&CheckCode::D406) || self.settings.enabled.contains(&CheckCode::D407) - || self.settings.enabled.contains(&CheckCode::D407) || self.settings.enabled.contains(&CheckCode::D408) || self.settings.enabled.contains(&CheckCode::D409) || self.settings.enabled.contains(&CheckCode::D410) @@ -2015,11 +2014,10 @@ impl<'a> Checker<'a> { || self.settings.enabled.contains(&CheckCode::D412) || self.settings.enabled.contains(&CheckCode::D413) || self.settings.enabled.contains(&CheckCode::D414) - || self.settings.enabled.contains(&CheckCode::D414) - || self.settings.enabled.contains(&CheckCode::D414) + || self.settings.enabled.contains(&CheckCode::D416) || self.settings.enabled.contains(&CheckCode::D417) { - docstring_checks::check_sections(self, &docstring); + docstring_plugins::sections(self, &docstring); } } } diff --git a/src/docstrings.rs b/src/docstrings.rs index 7e65a806a3..927d8e55c1 100644 --- a/src/docstrings.rs +++ b/src/docstrings.rs @@ -1,3 +1,8 @@ -pub mod docstring_checks; +pub mod docstring_plugins; +pub mod extraction; +mod google; +mod helpers; +mod numpy; pub mod sections; +mod styles; pub mod types; diff --git a/src/docstrings/docstring_checks.rs b/src/docstrings/docstring_plugins.rs similarity index 80% rename from src/docstrings/docstring_checks.rs rename to src/docstrings/docstring_plugins.rs index da57c49926..e58611662a 100644 --- a/src/docstrings/docstring_checks.rs +++ b/src/docstrings/docstring_plugins.rs @@ -2,101 +2,18 @@ use once_cell::sync::Lazy; use regex::Regex; -use rustpython_ast::{Constant, Expr, ExprKind, Location, Stmt, StmtKind}; +use rustpython_ast::{Constant, ExprKind, Location, StmtKind}; use crate::ast::types::Range; use crate::check_ast::Checker; use crate::checks::{Check, CheckCode, CheckKind}; -use crate::docstrings::sections::{ - check_google_section, check_numpy_section, section_contexts, SectionStyle, -}; -use crate::docstrings::types::{Definition, DefinitionKind, Documentable}; -use crate::visibility::{is_init, is_magic, is_overload, Modifier, Visibility, VisibleScope}; - -/// Extract a docstring from a function or class body. -pub fn docstring_from(suite: &[Stmt]) -> Option<&Expr> { - if let Some(stmt) = suite.first() { - if let StmtKind::Expr { value } = &stmt.node { - if matches!( - &value.node, - ExprKind::Constant { - value: Constant::Str(_), - .. - } - ) { - return Some(value); - } - } - } - None -} - -/// Extract a `Definition` from the AST node defined by a `Stmt`. -pub fn extract<'a>( - scope: &VisibleScope, - stmt: &'a Stmt, - body: &'a [Stmt], - kind: &Documentable, -) -> Definition<'a> { - let expr = docstring_from(body); - match kind { - Documentable::Function => match scope { - VisibleScope { - modifier: Modifier::Module, - .. - } => Definition { - kind: DefinitionKind::Function(stmt), - docstring: expr, - }, - VisibleScope { - modifier: Modifier::Class, - .. - } => Definition { - kind: DefinitionKind::Method(stmt), - docstring: expr, - }, - VisibleScope { - modifier: Modifier::Function, - .. - } => Definition { - kind: DefinitionKind::NestedFunction(stmt), - docstring: expr, - }, - }, - Documentable::Class => match scope { - VisibleScope { - modifier: Modifier::Module, - .. - } => Definition { - kind: DefinitionKind::Class(stmt), - docstring: expr, - }, - VisibleScope { - modifier: Modifier::Class, - .. - } => Definition { - kind: DefinitionKind::NestedClass(stmt), - docstring: expr, - }, - VisibleScope { - modifier: Modifier::Function, - .. - } => Definition { - kind: DefinitionKind::NestedClass(stmt), - docstring: expr, - }, - }, - } -} - -/// Extract the source code range for a docstring. -pub fn range_for(docstring: &Expr) -> Range { - // RustPython currently omits the first quotation mark in a string, so offset the location. - Range { - location: Location::new(docstring.location.row(), docstring.location.column() - 1), - end_location: docstring.end_location, - } -} +use crate::docstrings::google::check_google_section; +use crate::docstrings::helpers; +use crate::docstrings::numpy::check_numpy_section; +use crate::docstrings::sections::section_contexts; +use crate::docstrings::styles::SectionStyle; +use crate::docstrings::types::{Definition, DefinitionKind}; +use crate::visibility::{is_init, is_magic, is_overload, Visibility}; /// D100, D101, D102, D103, D104, D105, D106, D107 pub fn not_missing( @@ -218,7 +135,10 @@ pub fn one_liner(checker: &mut Checker, definition: &Definition) { } if non_empty_line_count == 1 && line_count > 1 { - checker.add_check(Check::new(CheckKind::FitsOnOneLine, range_for(docstring))); + checker.add_check(Check::new( + CheckKind::FitsOnOneLine, + helpers::range_for(docstring), + )); } } } @@ -241,9 +161,10 @@ pub fn blank_before_after_function(checker: &mut Checker, definition: &Definitio .. } = &docstring.node { - let (before, _, after) = checker - .locator - .partition_source_code_at(&Range::from_located(parent), &range_for(docstring)); + let (before, _, after) = checker.locator.partition_source_code_at( + &Range::from_located(parent), + &helpers::range_for(docstring), + ); if checker.settings.enabled.contains(&CheckCode::D201) { let blank_lines_before = before @@ -255,7 +176,7 @@ pub fn blank_before_after_function(checker: &mut Checker, definition: &Definitio if blank_lines_before != 0 { checker.add_check(Check::new( CheckKind::NoBlankLineBeforeFunction(blank_lines_before), - range_for(docstring), + helpers::range_for(docstring), )); } } @@ -280,7 +201,7 @@ pub fn blank_before_after_function(checker: &mut Checker, definition: &Definitio { checker.add_check(Check::new( CheckKind::NoBlankLineAfterFunction(blank_lines_after), - range_for(docstring), + helpers::range_for(docstring), )); } } @@ -300,9 +221,10 @@ pub fn blank_before_after_class(checker: &mut Checker, definition: &Definition) .. } = &docstring.node { - let (before, _, after) = checker - .locator - .partition_source_code_at(&Range::from_located(parent), &range_for(docstring)); + let (before, _, after) = checker.locator.partition_source_code_at( + &Range::from_located(parent), + &helpers::range_for(docstring), + ); if checker.settings.enabled.contains(&CheckCode::D203) || checker.settings.enabled.contains(&CheckCode::D211) @@ -318,7 +240,7 @@ pub fn blank_before_after_class(checker: &mut Checker, definition: &Definition) { checker.add_check(Check::new( CheckKind::NoBlankLineBeforeClass(blank_lines_before), - range_for(docstring), + helpers::range_for(docstring), )); } if blank_lines_before != 1 @@ -326,7 +248,7 @@ pub fn blank_before_after_class(checker: &mut Checker, definition: &Definition) { checker.add_check(Check::new( CheckKind::OneBlankLineBeforeClass(blank_lines_before), - range_for(docstring), + helpers::range_for(docstring), )); } } @@ -344,7 +266,7 @@ pub fn blank_before_after_class(checker: &mut Checker, definition: &Definition) if !all_blank_after && blank_lines_after != 1 { checker.add_check(Check::new( CheckKind::OneBlankLineAfterClass(blank_lines_after), - range_for(docstring), + helpers::range_for(docstring), )); } } @@ -374,7 +296,7 @@ pub fn blank_after_summary(checker: &mut Checker, definition: &Definition) { if lines_count > 1 && blanks_count != 1 { checker.add_check(Check::new( CheckKind::NoBlankLineAfterSummary, - range_for(docstring), + helpers::range_for(docstring), )); } } @@ -397,13 +319,13 @@ pub fn newline_after_last_paragraph(checker: &mut Checker, definition: &Definiti if line_count > 1 { let content = checker .locator - .slice_source_code_range(&range_for(docstring)); + .slice_source_code_range(&helpers::range_for(docstring)); if let Some(line) = content.lines().last() { let line = line.trim(); if line != "\"\"\"" && line != "'''" { checker.add_check(Check::new( CheckKind::NewLineAfterLastParagraph, - range_for(docstring), + helpers::range_for(docstring), )); } } @@ -430,7 +352,7 @@ pub fn no_surrounding_whitespace(checker: &mut Checker, definition: &Definition) if line.starts_with(' ') || (matches!(lines.next(), None) && line.ends_with(' ')) { checker.add_check(Check::new( CheckKind::NoSurroundingWhitespace, - range_for(docstring), + helpers::range_for(docstring), )); } } @@ -449,20 +371,20 @@ pub fn multi_line_summary_start(checker: &mut Checker, definition: &Definition) if string.lines().nth(1).is_some() { let content = checker .locator - .slice_source_code_range(&range_for(docstring)); + .slice_source_code_range(&helpers::range_for(docstring)); if let Some(first_line) = content.lines().next() { let first_line = first_line.trim(); if first_line == "\"\"\"" || first_line == "'''" { if checker.settings.enabled.contains(&CheckCode::D212) { checker.add_check(Check::new( CheckKind::MultiLineSummaryFirstLine, - range_for(docstring), + helpers::range_for(docstring), )); } } else if checker.settings.enabled.contains(&CheckCode::D213) { checker.add_check(Check::new( CheckKind::MultiLineSummarySecondLine, - range_for(docstring), + helpers::range_for(docstring), )); } } @@ -481,18 +403,18 @@ pub fn triple_quotes(checker: &mut Checker, definition: &Definition) { { let content = checker .locator - .slice_source_code_range(&range_for(docstring)); + .slice_source_code_range(&helpers::range_for(docstring)); if string.contains("\"\"\"") { if !content.starts_with("'''") { checker.add_check(Check::new( CheckKind::UsesTripleQuotes, - range_for(docstring), + helpers::range_for(docstring), )); } } else if !content.starts_with("\"\"\"") { checker.add_check(Check::new( CheckKind::UsesTripleQuotes, - range_for(docstring), + helpers::range_for(docstring), )); } } @@ -509,7 +431,10 @@ pub fn ends_with_period(checker: &mut Checker, definition: &Definition) { { if let Some(string) = string.lines().next() { if !string.ends_with('.') { - checker.add_check(Check::new(CheckKind::EndsInPeriod, range_for(docstring))); + checker.add_check(Check::new( + CheckKind::EndsInPeriod, + helpers::range_for(docstring), + )); } } } @@ -533,7 +458,7 @@ pub fn no_signature(checker: &mut Checker, definition: &Definition) { if first_line.contains(&format!("{name}(")) { checker.add_check(Check::new( CheckKind::NoSignature, - range_for(docstring), + helpers::range_for(docstring), )); } } @@ -568,7 +493,7 @@ pub fn capitalized(checker: &mut Checker, definition: &Definition) { if !first_char.is_uppercase() { checker.add_check(Check::new( CheckKind::FirstLineCapitalized, - range_for(docstring), + helpers::range_for(docstring), )); } } @@ -596,7 +521,10 @@ pub fn starts_with_this(checker: &mut Checker, definition: &Definition) { .to_lowercase() == "this" { - checker.add_check(Check::new(CheckKind::NoThisPrefix, range_for(docstring))); + checker.add_check(Check::new( + CheckKind::NoThisPrefix, + helpers::range_for(docstring), + )); } } } @@ -615,7 +543,7 @@ pub fn ends_with_punctuation(checker: &mut Checker, definition: &Definition) { if !(string.ends_with('.') || string.ends_with('!') || string.ends_with('?')) { checker.add_check(Check::new( CheckKind::EndsInPunctuation, - range_for(docstring), + helpers::range_for(docstring), )); } } @@ -650,7 +578,10 @@ pub fn not_empty(checker: &mut Checker, definition: &Definition) -> bool { { if string.trim().is_empty() { if checker.settings.enabled.contains(&CheckCode::D419) { - checker.add_check(Check::new(CheckKind::NonEmpty, range_for(docstring))); + checker.add_check(Check::new( + CheckKind::NonEmpty, + helpers::range_for(docstring), + )); } return false; } @@ -659,7 +590,8 @@ pub fn not_empty(checker: &mut Checker, definition: &Definition) -> bool { true } -pub fn check_sections(checker: &mut Checker, definition: &Definition) { +/// D212, D214, D215, D405, D406, D407, D408, D409, D410, D411, D412, D413, D414, D416, D417 +pub fn sections(checker: &mut Checker, definition: &Definition) { if let Some(docstring) = definition.docstring { if let ExprKind::Constant { value: Constant::Str(string), @@ -671,7 +603,7 @@ pub fn check_sections(checker: &mut Checker, definition: &Definition) { return; } - // First, try to interpret as NumPy-style sections. + // First, interpret as NumPy-style sections. let mut found_numpy_section = false; for context in §ion_contexts(&lines, &SectionStyle::NumPy) { found_numpy_section = true; diff --git a/src/docstrings/extraction.rs b/src/docstrings/extraction.rs new file mode 100644 index 0000000000..50588e4fb0 --- /dev/null +++ b/src/docstrings/extraction.rs @@ -0,0 +1,82 @@ +//! Extract docstrings from an AST. + +use rustpython_ast::{Constant, Expr, ExprKind, Stmt, StmtKind}; + +use crate::docstrings::types::{Definition, DefinitionKind, Documentable}; +use crate::visibility::{Modifier, VisibleScope}; + +/// Extract a docstring from a function or class body. +pub fn docstring_from(suite: &[Stmt]) -> Option<&Expr> { + if let Some(stmt) = suite.first() { + if let StmtKind::Expr { value } = &stmt.node { + if matches!( + &value.node, + ExprKind::Constant { + value: Constant::Str(_), + .. + } + ) { + return Some(value); + } + } + } + None +} + +/// Extract a `Definition` from the AST node defined by a `Stmt`. +pub fn extract<'a>( + scope: &VisibleScope, + stmt: &'a Stmt, + body: &'a [Stmt], + kind: &Documentable, +) -> Definition<'a> { + let expr = docstring_from(body); + match kind { + Documentable::Function => match scope { + VisibleScope { + modifier: Modifier::Module, + .. + } => Definition { + kind: DefinitionKind::Function(stmt), + docstring: expr, + }, + VisibleScope { + modifier: Modifier::Class, + .. + } => Definition { + kind: DefinitionKind::Method(stmt), + docstring: expr, + }, + VisibleScope { + modifier: Modifier::Function, + .. + } => Definition { + kind: DefinitionKind::NestedFunction(stmt), + docstring: expr, + }, + }, + Documentable::Class => match scope { + VisibleScope { + modifier: Modifier::Module, + .. + } => Definition { + kind: DefinitionKind::Class(stmt), + docstring: expr, + }, + VisibleScope { + modifier: Modifier::Class, + .. + } => Definition { + kind: DefinitionKind::NestedClass(stmt), + docstring: expr, + }, + VisibleScope { + modifier: Modifier::Function, + .. + } => Definition { + kind: DefinitionKind::NestedClass(stmt), + docstring: expr, + }, + }, + } +} diff --git a/src/docstrings/google.rs b/src/docstrings/google.rs new file mode 100644 index 0000000000..b260405116 --- /dev/null +++ b/src/docstrings/google.rs @@ -0,0 +1,150 @@ +//! Abstractions for Google-style docstrings. + +use std::collections::BTreeSet; + +use once_cell::sync::Lazy; +use regex::Regex; + +use crate::check_ast::Checker; +use crate::checks::{Check, CheckCode, CheckKind}; +use crate::docstrings::helpers::range_for; +use crate::docstrings::sections; +use crate::docstrings::sections::SectionContext; +use crate::docstrings::styles::SectionStyle; +use crate::docstrings::types::Definition; + +pub(crate) static GOOGLE_SECTION_NAMES: Lazy> = Lazy::new(|| { + BTreeSet::from([ + "Args", + "Arguments", + "Attention", + "Attributes", + "Caution", + "Danger", + "Error", + "Example", + "Examples", + "Hint", + "Important", + "Keyword Args", + "Keyword Arguments", + "Methods", + "Note", + "Notes", + "Return", + "Returns", + "Raises", + "References", + "See Also", + "Tip", + "Todo", + "Warning", + "Warnings", + "Warns", + "Yield", + "Yields", + ]) +}); + +pub(crate) static LOWERCASE_GOOGLE_SECTION_NAMES: Lazy> = Lazy::new(|| { + BTreeSet::from([ + "args", + "arguments", + "attention", + "attributes", + "caution", + "danger", + "error", + "example", + "examples", + "hint", + "important", + "keyword args", + "keyword arguments", + "methods", + "note", + "notes", + "return", + "returns", + "raises", + "references", + "see also", + "tip", + "todo", + "warning", + "warnings", + "warns", + "yield", + "yields", + ]) +}); + +// See: `GOOGLE_ARGS_REGEX` in `pydocstyle/checker.py`. +static GOOGLE_ARGS_REGEX: Lazy = + Lazy::new(|| Regex::new(r"^\s*(\w+)\s*(\(.*?\))?\s*:\n?\s*.+").expect("Invalid regex")); + +fn check_args_section(checker: &mut Checker, definition: &Definition, context: &SectionContext) { + let mut args_sections: Vec = vec![]; + for line in textwrap::dedent(&context.following_lines.join("\n")).lines() { + if line + .chars() + .next() + .map(|char| char.is_whitespace()) + .unwrap_or(true) + { + // This is a continuation of documentation for the last + // parameter because it does start with whitespace. + if let Some(current) = args_sections.last_mut() { + current.push_str(line); + } + } else { + // This line is the start of documentation for the next + // parameter because it doesn't start with any whitespace. + args_sections.push(line.to_string()); + } + } + + sections::check_missing_args( + checker, + definition, + // Collect the list of arguments documented in the docstring. + &BTreeSet::from_iter(args_sections.iter().filter_map(|section| { + match GOOGLE_ARGS_REGEX.captures(section.as_str()) { + Some(caps) => caps.get(1).map(|arg_name| arg_name.as_str()), + None => None, + } + })), + ) +} + +pub(crate) fn check_google_section( + checker: &mut Checker, + definition: &Definition, + context: &SectionContext, +) { + sections::check_common_section(checker, definition, context, &SectionStyle::Google); + + if checker.settings.enabled.contains(&CheckCode::D416) { + let suffix = context + .line + .trim() + .strip_prefix(&context.section_name) + .unwrap(); + if suffix != ":" { + let docstring = definition + .docstring + .expect("Sections are only available for docstrings."); + checker.add_check(Check::new( + CheckKind::SectionNameEndsInColon(context.section_name.to_string()), + range_for(docstring), + )) + } + } + + if checker.settings.enabled.contains(&CheckCode::D417) { + let capitalized_section_name = titlecase::titlecase(&context.section_name); + if capitalized_section_name == "Args" || capitalized_section_name == "Arguments" { + check_args_section(checker, definition, context); + } + } +} diff --git a/src/docstrings/helpers.rs b/src/docstrings/helpers.rs new file mode 100644 index 0000000000..57be7e6a49 --- /dev/null +++ b/src/docstrings/helpers.rs @@ -0,0 +1,37 @@ +use rustpython_ast::{Expr, Location}; + +use crate::ast::types::Range; +use crate::check_ast::Checker; + +/// Extract the leading words from a line of text. +pub fn leading_words(line: &str) -> String { + line.trim() + .chars() + .take_while(|char| char.is_alphanumeric() || char.is_whitespace()) + .collect() +} + +/// Extract the leading whitespace from a line of text. +pub fn leading_space(line: &str) -> String { + line.chars() + .take_while(|char| char.is_whitespace()) + .collect() +} + +/// Extract the leading indentation from a docstring. +pub fn indentation<'a>(checker: &'a mut Checker, docstring: &Expr) -> &'a str { + let range = range_for(docstring); + checker.locator.slice_source_code_range(&Range { + location: Location::new(range.location.row(), 1), + end_location: Location::new(range.location.row(), range.location.column()), + }) +} + +/// Extract the source code range for a docstring. +pub fn range_for(docstring: &Expr) -> Range { + // RustPython currently omits the first quotation mark in a string, so offset the location. + Range { + location: Location::new(docstring.location.row(), docstring.location.column() - 1), + end_location: docstring.end_location, + } +} diff --git a/src/docstrings/numpy.rs b/src/docstrings/numpy.rs new file mode 100644 index 0000000000..5719fe72ee --- /dev/null +++ b/src/docstrings/numpy.rs @@ -0,0 +1,112 @@ +//! Abstractions for NumPy-style docstrings. + +use crate::check_ast::Checker; +use crate::checks::{Check, CheckCode, CheckKind}; +use crate::docstrings::helpers::range_for; +use crate::docstrings::sections::SectionContext; +use crate::docstrings::styles::SectionStyle; +use crate::docstrings::types::Definition; +use crate::docstrings::{helpers, sections}; +use once_cell::sync::Lazy; +use std::collections::BTreeSet; + +pub(crate) static LOWERCASE_NUMPY_SECTION_NAMES: Lazy> = Lazy::new(|| { + BTreeSet::from([ + "short summary", + "extended summary", + "parameters", + "returns", + "yields", + "other parameters", + "raises", + "see also", + "notes", + "references", + "examples", + "attributes", + "methods", + ]) +}); + +pub(crate) static NUMPY_SECTION_NAMES: Lazy> = Lazy::new(|| { + BTreeSet::from([ + "Short Summary", + "Extended Summary", + "Parameters", + "Returns", + "Yields", + "Other Parameters", + "Raises", + "See Also", + "Notes", + "References", + "Examples", + "Attributes", + "Methods", + ]) +}); + +fn check_parameters_section( + checker: &mut Checker, + definition: &Definition, + context: &SectionContext, +) { + // Collect the list of arguments documented in the docstring. + let mut docstring_args: BTreeSet<&str> = Default::default(); + let section_level_indent = helpers::leading_space(context.line); + for i in 1..context.following_lines.len() { + let current_line = context.following_lines[i - 1]; + let current_leading_space = helpers::leading_space(current_line); + let next_line = context.following_lines[i]; + if current_leading_space == section_level_indent + && (helpers::leading_space(next_line).len() > current_leading_space.len()) + && !next_line.trim().is_empty() + { + let parameters = if let Some(semi_index) = current_line.find(':') { + // If the parameter has a type annotation, exclude it. + ¤t_line[..semi_index] + } else { + // Otherwise, it's just a list of parameters on the current line. + current_line.trim() + }; + // Notably, NumPy lets you put multiple parameters of the same type on the same line. + for parameter in parameters.split(',') { + docstring_args.insert(parameter.trim()); + } + } + } + // Validate that all arguments were documented. + sections::check_missing_args(checker, definition, &docstring_args); +} + +pub(crate) fn check_numpy_section( + checker: &mut Checker, + definition: &Definition, + context: &SectionContext, +) { + sections::check_common_section(checker, definition, context, &SectionStyle::NumPy); + + if checker.settings.enabled.contains(&CheckCode::D406) { + let suffix = context + .line + .trim() + .strip_prefix(&context.section_name) + .unwrap(); + if !suffix.is_empty() { + let docstring = definition + .docstring + .expect("Sections are only available for docstrings."); + checker.add_check(Check::new( + CheckKind::NewLineAfterSectionName(context.section_name.to_string()), + range_for(docstring), + )) + } + } + + if checker.settings.enabled.contains(&CheckCode::D417) { + let capitalized_section_name = titlecase::titlecase(&context.section_name); + if capitalized_section_name == "Parameters" { + check_parameters_section(checker, definition, context); + } + } +} diff --git a/src/docstrings/sections.rs b/src/docstrings/sections.rs index 6b3327ac52..83a47c326f 100644 --- a/src/docstrings/sections.rs +++ b/src/docstrings/sections.rs @@ -1,176 +1,32 @@ -use itertools::Itertools; use std::collections::BTreeSet; -use once_cell::sync::Lazy; -use regex::Regex; -use rustpython_ast::{Arg, Expr, Location, StmtKind}; +use itertools::Itertools; +use rustpython_ast::{Arg, StmtKind}; use titlecase::titlecase; use crate::ast::types::Range; use crate::check_ast::Checker; use crate::checks::{Check, CheckCode, CheckKind}; -use crate::docstrings::docstring_checks::range_for; +use crate::docstrings::helpers; +use crate::docstrings::helpers::range_for; +use crate::docstrings::styles::SectionStyle; use crate::docstrings::types::{Definition, DefinitionKind}; use crate::visibility::is_static; -static NUMPY_SECTION_NAMES: Lazy> = Lazy::new(|| { - BTreeSet::from([ - "Short Summary", - "Extended Summary", - "Parameters", - "Returns", - "Yields", - "Other Parameters", - "Raises", - "See Also", - "Notes", - "References", - "Examples", - "Attributes", - "Methods", - ]) -}); - -static LOWERCASE_NUMPY_SECTION_NAMES: Lazy> = Lazy::new(|| { - BTreeSet::from([ - "short summary", - "extended summary", - "parameters", - "returns", - "yields", - "other parameters", - "raises", - "see also", - "notes", - "references", - "examples", - "attributes", - "methods", - ]) -}); - -static GOOGLE_SECTION_NAMES: Lazy> = Lazy::new(|| { - BTreeSet::from([ - "Args", - "Arguments", - "Attention", - "Attributes", - "Caution", - "Danger", - "Error", - "Example", - "Examples", - "Hint", - "Important", - "Keyword Args", - "Keyword Arguments", - "Methods", - "Note", - "Notes", - "Return", - "Returns", - "Raises", - "References", - "See Also", - "Tip", - "Todo", - "Warning", - "Warnings", - "Warns", - "Yield", - "Yields", - ]) -}); - -static LOWERCASE_GOOGLE_SECTION_NAMES: Lazy> = Lazy::new(|| { - BTreeSet::from([ - "args", - "arguments", - "attention", - "attributes", - "caution", - "danger", - "error", - "example", - "examples", - "hint", - "important", - "keyword args", - "keyword arguments", - "methods", - "note", - "notes", - "return", - "returns", - "raises", - "references", - "see also", - "tip", - "todo", - "warning", - "warnings", - "warns", - "yield", - "yields", - ]) -}); - -pub enum SectionStyle { - NumPy, - Google, -} - -impl SectionStyle { - fn section_names(&self) -> &Lazy> { - match self { - SectionStyle::NumPy => &NUMPY_SECTION_NAMES, - SectionStyle::Google => &GOOGLE_SECTION_NAMES, - } - } - - fn lowercase_section_names(&self) -> &Lazy> { - match self { - SectionStyle::NumPy => &LOWERCASE_NUMPY_SECTION_NAMES, - SectionStyle::Google => &LOWERCASE_GOOGLE_SECTION_NAMES, - } - } -} - -fn indentation<'a>(checker: &'a mut Checker, docstring: &Expr) -> &'a str { - let range = range_for(docstring); - checker.locator.slice_source_code_range(&Range { - location: Location::new(range.location.row(), 1), - end_location: Location::new(range.location.row(), range.location.column()), - }) -} - -fn leading_space(line: &str) -> String { - line.chars() - .take_while(|char| char.is_whitespace()) - .collect() -} - -fn leading_words(line: &str) -> String { - line.trim() - .chars() - .take_while(|char| char.is_alphanumeric() || char.is_whitespace()) - .collect() +#[derive(Debug)] +pub(crate) struct SectionContext<'a> { + pub(crate) section_name: String, + pub(crate) previous_line: &'a str, + pub(crate) line: &'a str, + pub(crate) following_lines: &'a [&'a str], + pub(crate) is_last_section: bool, + original_index: usize, } fn suspected_as_section(line: &str, style: &SectionStyle) -> bool { style .lowercase_section_names() - .contains(&leading_words(line).to_lowercase().as_str()) -} - -#[derive(Debug)] -pub struct SectionContext<'a> { - section_name: String, - previous_line: &'a str, - line: &'a str, - following_lines: &'a [&'a str], - original_index: usize, - is_last_section: bool, + .contains(&helpers::leading_words(line).to_lowercase().as_str()) } /// Check if the suspected context is really a section header. @@ -201,7 +57,10 @@ fn is_docstring_section(context: &SectionContext) -> bool { } /// Extract all `SectionContext` values from a docstring. -pub fn section_contexts<'a>(lines: &'a [&'a str], style: &SectionStyle) -> Vec> { +pub(crate) fn section_contexts<'a>( + lines: &'a [&'a str], + style: &SectionStyle, +) -> Vec> { let suspected_section_indices: Vec = lines .iter() .enumerate() @@ -217,7 +76,7 @@ pub fn section_contexts<'a>(lines: &'a [&'a str], style: &SectionStyle) -> Vec indentation(checker, docstring).len() { + if helpers::leading_space(non_empty_line).len() + > helpers::indentation(checker, docstring).len() + { checker.add_check(Check::new( CheckKind::SectionUnderlineNotOverIndented(context.section_name.to_string()), range_for(docstring), @@ -378,7 +239,7 @@ fn check_blanks_and_section_underline( } } -fn check_common_section( +pub(crate) fn check_common_section( checker: &mut Checker, definition: &Definition, context: &SectionContext, @@ -404,7 +265,9 @@ fn check_common_section( } if checker.settings.enabled.contains(&CheckCode::D214) { - if leading_space(context.line).len() > indentation(checker, docstring).len() { + if helpers::leading_space(context.line).len() + > helpers::indentation(checker, docstring).len() + { checker.add_check(Check::new( CheckKind::SectionNotOverIndented(context.section_name.to_string()), range_for(docstring), @@ -443,9 +306,11 @@ fn check_common_section( )) } } + + check_blanks_and_section_underline(checker, definition, context); } -fn check_missing_args( +pub(crate) fn check_missing_args( checker: &mut Checker, definition: &Definition, docstrings_args: &BTreeSet<&str>, @@ -510,139 +375,3 @@ fn check_missing_args( } } } - -fn check_parameters_section( - checker: &mut Checker, - definition: &Definition, - context: &SectionContext, -) { - // Collect the list of arguments documented in the docstring. - let mut docstring_args: BTreeSet<&str> = Default::default(); - let section_level_indent = leading_space(context.line); - for i in 1..context.following_lines.len() { - let current_line = context.following_lines[i - 1]; - let current_leading_space = leading_space(current_line); - let next_line = context.following_lines[i]; - if current_leading_space == section_level_indent - && (leading_space(next_line).len() > current_leading_space.len()) - && !next_line.trim().is_empty() - { - let parameters = if let Some(semi_index) = current_line.find(':') { - // If the parameter has a type annotation, exclude it. - ¤t_line[..semi_index] - } else { - // Otherwise, it's just a list of parameters on the current line. - current_line.trim() - }; - // Notably, NumPy lets you put multiple parameters of the same type on the same line. - for parameter in parameters.split(',') { - docstring_args.insert(parameter.trim()); - } - } - } - // Validate that all arguments were documented. - check_missing_args(checker, definition, &docstring_args); -} - -pub fn check_numpy_section( - checker: &mut Checker, - definition: &Definition, - context: &SectionContext, -) { - check_common_section(checker, definition, context, &SectionStyle::NumPy); - check_blanks_and_section_underline(checker, definition, context); - - if checker.settings.enabled.contains(&CheckCode::D406) { - let suffix = context - .line - .trim() - .strip_prefix(&context.section_name) - .unwrap(); - if !suffix.is_empty() { - let docstring = definition - .docstring - .expect("Sections are only available for docstrings."); - checker.add_check(Check::new( - CheckKind::NewLineAfterSectionName(context.section_name.to_string()), - range_for(docstring), - )) - } - } - - if checker.settings.enabled.contains(&CheckCode::D417) { - if titlecase(&context.section_name) == "Parameters" { - check_parameters_section(checker, definition, context); - } - } -} - -// See: `GOOGLE_ARGS_REGEX` in `pydocstyle/checker.py`. -static GOOGLE_ARGS_REGEX: Lazy = - Lazy::new(|| Regex::new(r"^\s*(\w+)\s*(\(.*?\))?\s*:\n?\s*.+").expect("Invalid regex")); - -fn check_args_section(checker: &mut Checker, definition: &Definition, context: &SectionContext) { - let mut args_sections: Vec = vec![]; - for line in textwrap::dedent(&context.following_lines.join("\n")).lines() { - if line - .chars() - .next() - .map(|char| char.is_whitespace()) - .unwrap_or(true) - { - // This is a continuation of documentation for the last - // parameter because it does start with whitespace. - if let Some(current) = args_sections.last_mut() { - current.push_str(line); - } - } else { - // This line is the start of documentation for the next - // parameter because it doesn't start with any whitespace. - args_sections.push(line.to_string()); - } - } - - check_missing_args( - checker, - definition, - // Collect the list of arguments documented in the docstring. - &BTreeSet::from_iter(args_sections.iter().filter_map(|section| { - match GOOGLE_ARGS_REGEX.captures(section.as_str()) { - Some(caps) => caps.get(1).map(|arg_name| arg_name.as_str()), - None => None, - } - })), - ) -} - -pub fn check_google_section( - checker: &mut Checker, - definition: &Definition, - context: &SectionContext, -) { - check_common_section(checker, definition, context, &SectionStyle::Google); - check_blanks_and_section_underline(checker, definition, context); - - if checker.settings.enabled.contains(&CheckCode::D416) { - let suffix = context - .line - .trim() - .strip_prefix(&context.section_name) - .unwrap(); - if suffix != ":" { - let docstring = definition - .docstring - .expect("Sections are only available for docstrings."); - checker.add_check(Check::new( - CheckKind::SectionNameEndsInColon(context.section_name.to_string()), - range_for(docstring), - )) - } - } - - if checker.settings.enabled.contains(&CheckCode::D417) { - let capitalized_section_name = titlecase(&context.section_name); - if capitalized_section_name == "Args" || capitalized_section_name == "Arguments" { - check_args_section(checker, definition, context); - } - } -} diff --git a/src/docstrings/styles.rs b/src/docstrings/styles.rs new file mode 100644 index 0000000000..e19597f950 --- /dev/null +++ b/src/docstrings/styles.rs @@ -0,0 +1,27 @@ +use std::collections::BTreeSet; + +use once_cell::sync::Lazy; + +use crate::docstrings::google::{GOOGLE_SECTION_NAMES, LOWERCASE_GOOGLE_SECTION_NAMES}; +use crate::docstrings::numpy::{LOWERCASE_NUMPY_SECTION_NAMES, NUMPY_SECTION_NAMES}; + +pub(crate) enum SectionStyle { + NumPy, + Google, +} + +impl SectionStyle { + pub(crate) fn section_names(&self) -> &Lazy> { + match self { + SectionStyle::NumPy => &NUMPY_SECTION_NAMES, + SectionStyle::Google => &GOOGLE_SECTION_NAMES, + } + } + + pub(crate) fn lowercase_section_names(&self) -> &Lazy> { + match self { + SectionStyle::NumPy => &LOWERCASE_NUMPY_SECTION_NAMES, + SectionStyle::Google => &LOWERCASE_GOOGLE_SECTION_NAMES, + } + } +}