ruff/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs

559 lines
16 KiB
Rust

use itertools::Itertools;
use ruff_diagnostics::Diagnostic;
use ruff_diagnostics::Violation;
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::name::QualifiedName;
use ruff_python_ast::statement_visitor::StatementVisitor;
use ruff_python_ast::{self as ast, statement_visitor, Expr, Stmt};
use ruff_python_semantic::{Definition, MemberKind, SemanticModel};
use ruff_text_size::{Ranged, TextRange};
use crate::checkers::ast::Checker;
use crate::docstrings::sections::{SectionContext, SectionContexts, SectionKind};
use crate::docstrings::styles::SectionStyle;
use crate::registry::Rule;
use crate::rules::pydocstyle::settings::Convention;
/// ## What it does
/// Checks for functions with explicit returns missing a returns section in
/// their docstring.
///
/// ## Why is this bad?
/// Docstrings missing return sections are a sign of incomplete documentation
/// or refactors.
///
/// ## Example
/// ```python
/// def calculate_speed(distance: float, time: float) -> float:
/// """Calculate speed as distance divided by time.
///
/// Args:
/// distance: Distance traveled.
/// time: Time spent traveling.
/// """
/// return distance / time
/// ```
///
/// Use instead:
/// ```python
/// def calculate_speed(distance: float, time: float) -> float:
/// """Calculate speed as distance divided by time.
///
/// Args:
/// distance: Distance traveled.
/// time: Time spent traveling.
///
/// Returns:
/// Speed as distance divided by time.
/// """
/// return distance / time
/// ```
#[violation]
pub struct DocstringMissingReturns;
impl Violation for DocstringMissingReturns {
#[derive_message_formats]
fn message(&self) -> String {
format!("`return` is not documented in docstring")
}
}
/// ## What it does
/// Checks for function docstrings that have a returns section without
/// needing one.
///
/// ## Why is this bad?
/// Functions without an explicit return should not have a returns section
/// in their docstrings.
///
/// ## Example
/// ```python
/// def say_hello(n: int) -> None:
/// """Says hello to the user.
///
/// Args:
/// n: Number of times to say hello.
///
/// Returns:
/// Doesn't return anything.
/// """
/// for _ in range(n):
/// print("Hello!")
/// ```
///
/// Use instead:
/// ```python
/// def say_hello(n: int) -> None:
/// """Says hello to the user.
///
/// Args:
/// n: Number of times to say hello.
/// """
/// for _ in range(n):
/// print("Hello!")
/// ```
#[violation]
pub struct DocstringExtraneousReturns;
impl Violation for DocstringExtraneousReturns {
#[derive_message_formats]
fn message(&self) -> String {
format!("Docstring should not have a returns section because the function doesn't return anything")
}
}
/// ## What it does
/// Checks for function docstrings that do not include documentation for all
/// explicitly-raised exceptions.
///
/// ## Why is this bad?
/// If a function raises an exception without documenting it in its docstring,
/// it can be misleading to users and/or a sign of incomplete documentation or
/// refactors.
///
/// ## Example
/// ```python
/// def calculate_speed(distance: float, time: float) -> float:
/// """Calculate speed as distance divided by time.
///
/// Args:
/// distance: Distance traveled.
/// time: Time spent traveling.
///
/// Returns:
/// Speed as distance divided by time.
/// """
/// try:
/// return distance / time
/// except ZeroDivisionError as exc:
/// raise FasterThanLightError from exc
/// ```
///
/// Use instead:
/// ```python
/// def calculate_speed(distance: float, time: float) -> float:
/// """Calculate speed as distance divided by time.
///
/// Args:
/// distance: Distance traveled.
/// time: Time spent traveling.
///
/// Returns:
/// Speed as distance divided by time.
///
/// Raises:
/// FasterThanLightError: If speed is greater than the speed of light.
/// """
/// try:
/// return distance / time
/// except ZeroDivisionError as exc:
/// raise FasterThanLightError from exc
/// ```
#[violation]
pub struct DocstringMissingException {
id: String,
}
impl Violation for DocstringMissingException {
#[derive_message_formats]
fn message(&self) -> String {
let DocstringMissingException { id } = self;
format!("Raised exception `{id}` missing from docstring")
}
}
/// ## What it does
/// Checks for function docstrings that include exceptions which are not
/// explicitly raised.
///
/// ## Why is this bad?
/// Some conventions prefer non-explicit exceptions be omitted from the
/// docstring.
///
/// ## Example
/// ```python
/// def calculate_speed(distance: float, time: float) -> float:
/// """Calculate speed as distance divided by time.
///
/// Args:
/// distance: Distance traveled.
/// time: Time spent traveling.
///
/// Returns:
/// Speed as distance divided by time.
///
/// Raises:
/// ZeroDivisionError: Divided by zero.
/// """
/// return distance / time
/// ```
///
/// Use instead:
/// ```python
/// def calculate_speed(distance: float, time: float) -> float:
/// """Calculate speed as distance divided by time.
///
/// Args:
/// distance: Distance traveled.
/// time: Time spent traveling.
///
/// Returns:
/// Speed as distance divided by time.
/// """
/// return distance / time
/// ```
#[violation]
pub struct DocstringExtraneousException {
ids: Vec<String>,
}
impl Violation for DocstringExtraneousException {
#[derive_message_formats]
fn message(&self) -> String {
let DocstringExtraneousException { ids } = self;
if let [id] = ids.as_slice() {
format!("Raised exception is not explicitly raised: `{id}`")
} else {
format!(
"Raised exceptions are not explicitly raised: {}",
ids.iter().map(|id| format!("`{id}`")).join(", ")
)
}
}
}
// A generic docstring section.
#[derive(Debug)]
struct GenericSection {
range: TextRange,
}
impl Ranged for GenericSection {
fn range(&self) -> TextRange {
self.range
}
}
impl GenericSection {
fn from_section(section: &SectionContext) -> Self {
Self {
range: section.range(),
}
}
}
// A Raises docstring section.
#[derive(Debug)]
struct RaisesSection<'a> {
raised_exceptions: Vec<QualifiedName<'a>>,
range: TextRange,
}
impl Ranged for RaisesSection<'_> {
fn range(&self) -> TextRange {
self.range
}
}
impl<'a> RaisesSection<'a> {
/// Return the raised exceptions for the docstring, or `None` if the docstring does not contain
/// a `Raises` section.
fn from_section(section: &SectionContext<'a>, style: SectionStyle) -> Self {
Self {
raised_exceptions: parse_entries(section.following_lines_str(), style),
range: section.range(),
}
}
}
#[derive(Debug)]
struct DocstringSections<'a> {
returns: Option<GenericSection>,
raises: Option<RaisesSection<'a>>,
}
impl<'a> DocstringSections<'a> {
fn from_sections(sections: &'a SectionContexts, style: SectionStyle) -> Self {
let mut returns: Option<GenericSection> = None;
let mut raises: Option<RaisesSection> = None;
for section in sections.iter() {
match section.kind() {
SectionKind::Raises => raises = Some(RaisesSection::from_section(&section, style)),
SectionKind::Returns => returns = Some(GenericSection::from_section(&section)),
_ => continue,
}
}
Self { returns, raises }
}
}
/// Parse the entries in a `Raises` section of a docstring.
fn parse_entries(content: &str, style: SectionStyle) -> Vec<QualifiedName> {
match style {
SectionStyle::Google => parse_entries_google(content),
SectionStyle::Numpy => parse_entries_numpy(content),
}
}
/// Parses Google-style docstring sections of the form:
///
/// ```python
/// Raises:
/// FasterThanLightError: If speed is greater than the speed of light.
/// DivisionByZero: If attempting to divide by zero.
/// ```
fn parse_entries_google(content: &str) -> Vec<QualifiedName> {
let mut entries: Vec<QualifiedName> = 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));
}
entries
}
/// Parses NumPy-style docstring sections of the form:
///
/// ```python
/// Raises
/// ------
/// FasterThanLightError
/// If speed is greater than the speed of light.
/// DivisionByZero
/// If attempting to divide by zero.
/// ```
fn parse_entries_numpy(content: &str) -> Vec<QualifiedName> {
let mut entries: Vec<QualifiedName> = Vec::new();
let mut lines = content.lines();
let Some(dashes) = lines.next() else {
return entries;
};
let indentation = &dashes[..dashes.len() - dashes.trim_start().len()];
for potential in lines {
if let Some(entry) = potential.strip_prefix(indentation) {
if let Some(first_char) = entry.chars().next() {
if !first_char.is_whitespace() {
entries.push(QualifiedName::user_defined(entry.trim_end()));
}
}
}
}
entries
}
/// An individual documentable statement in a function body.
#[derive(Debug)]
struct Entry {
range: TextRange,
}
impl Ranged for Entry {
fn range(&self) -> TextRange {
self.range
}
}
/// An individual exception raised in a function body.
#[derive(Debug)]
struct ExceptionEntry<'a> {
qualified_name: QualifiedName<'a>,
range: TextRange,
}
impl Ranged for ExceptionEntry<'_> {
fn range(&self) -> TextRange {
self.range
}
}
/// A summary of documentable statements from the function body
#[derive(Debug)]
struct BodyEntries<'a> {
returns: Vec<Entry>,
raised_exceptions: Vec<ExceptionEntry<'a>>,
}
/// An AST visitor to extract a summary of documentable statements from a function body.
struct BodyVisitor<'a> {
returns: Vec<Entry>,
raised_exceptions: Vec<ExceptionEntry<'a>>,
semantic: &'a SemanticModel<'a>,
}
impl<'a> BodyVisitor<'a> {
fn new(semantic: &'a SemanticModel) -> Self {
Self {
returns: Vec::new(),
raised_exceptions: Vec::new(),
semantic,
}
}
fn finish(self) -> BodyEntries<'a> {
BodyEntries {
returns: self.returns,
raised_exceptions: self.raised_exceptions,
}
}
}
impl<'a> StatementVisitor<'a> for BodyVisitor<'a> {
fn visit_stmt(&mut self, stmt: &'a Stmt) {
match stmt {
Stmt::Raise(ast::StmtRaise { exc: Some(exc), .. }) => {
if let Some(qualified_name) = extract_raised_exception(self.semantic, exc.as_ref())
{
self.raised_exceptions.push(ExceptionEntry {
qualified_name,
range: exc.as_ref().range(),
});
}
}
Stmt::Return(ast::StmtReturn {
range,
value: Some(_),
}) => {
self.returns.push(Entry { range: *range });
}
Stmt::FunctionDef(_) | Stmt::ClassDef(_) => return,
_ => {}
}
statement_visitor::walk_stmt(self, stmt);
}
}
fn extract_raised_exception<'a>(
semantic: &SemanticModel<'a>,
exc: &'a Expr,
) -> Option<QualifiedName<'a>> {
if let Some(qualified_name) = semantic.resolve_qualified_name(exc) {
return Some(qualified_name);
}
if let Expr::Call(ast::ExprCall { func, .. }) = exc {
return extract_raised_exception(semantic, func.as_ref());
}
None
}
/// DOC201, DOC202, DOC501, DOC502
pub(crate) fn check_docstring(
checker: &mut Checker,
definition: &Definition,
section_contexts: &SectionContexts,
convention: Option<Convention>,
) {
let mut diagnostics = Vec::new();
let Definition::Member(member) = definition else {
return;
};
// Only check function docstrings.
if matches!(
member.kind,
MemberKind::Class(_) | MemberKind::NestedClass(_)
) {
return;
}
// Prioritize the specified convention over the determined style.
let docstring_sections = match convention {
Some(Convention::Google) => {
DocstringSections::from_sections(section_contexts, SectionStyle::Google)
}
Some(Convention::Numpy) => {
DocstringSections::from_sections(section_contexts, SectionStyle::Numpy)
}
_ => DocstringSections::from_sections(section_contexts, section_contexts.style()),
};
let body_entries = {
let mut visitor = BodyVisitor::new(checker.semantic());
visitor.visit_body(member.body());
visitor.finish()
};
// DOC201
if checker.enabled(Rule::DocstringMissingReturns) && docstring_sections.returns.is_none() {
let extra_property_decorators = checker.settings.pydocstyle.property_decorators();
if !definition.is_property(extra_property_decorators, checker.semantic()) {
if let Some(body_return) = body_entries.returns.first() {
let diagnostic = Diagnostic::new(DocstringMissingReturns, body_return.range());
diagnostics.push(diagnostic);
}
}
}
// DOC202
if checker.enabled(Rule::DocstringExtraneousReturns) {
if let Some(docstring_returns) = docstring_sections.returns {
if body_entries.returns.is_empty() {
let diagnostic =
Diagnostic::new(DocstringExtraneousReturns, docstring_returns.range());
diagnostics.push(diagnostic);
}
}
}
// DOC501
if checker.enabled(Rule::DocstringMissingException) {
for body_raise in &body_entries.raised_exceptions {
let Some(name) = body_raise.qualified_name.segments().last() else {
continue;
};
if *name == "NotImplementedError" {
continue;
}
if !docstring_sections.raises.as_ref().is_some_and(|section| {
section.raised_exceptions.iter().any(|exception| {
body_raise
.qualified_name
.segments()
.ends_with(exception.segments())
})
}) {
let diagnostic = Diagnostic::new(
DocstringMissingException {
id: (*name).to_string(),
},
body_raise.range(),
);
diagnostics.push(diagnostic);
}
}
}
// DOC502
if checker.enabled(Rule::DocstringExtraneousException) {
if let Some(docstring_raises) = docstring_sections.raises {
let mut extraneous_exceptions = Vec::new();
for docstring_raise in &docstring_raises.raised_exceptions {
if !body_entries.raised_exceptions.iter().any(|exception| {
exception
.qualified_name
.segments()
.ends_with(docstring_raise.segments())
}) {
extraneous_exceptions.push(docstring_raise.to_string());
}
}
if !extraneous_exceptions.is_empty() {
let diagnostic = Diagnostic::new(
DocstringExtraneousException {
ids: extraneous_exceptions,
},
docstring_raises.range(),
);
diagnostics.push(diagnostic);
}
}
}
checker.diagnostics.extend(diagnostics);
}