Port pydocstyle code 401 (ImperativeMood) (#1999)

This adds support for pydocstyle code D401 using the `imperative` crate.
This commit is contained in:
Aarni Koskela 2023-01-20 14:18:27 +02:00 committed by GitHub
parent 81db00a3c4
commit bea6deb0c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 208 additions and 6 deletions

39
Cargo.lock generated
View file

@ -926,6 +926,16 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "imperative"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f92123bf2fe0d9f1b5df1964727b970ca3b2d0203d47cf97fb1f36d856b6398"
dependencies = [
"phf 0.11.1",
"rust-stemmers",
]
[[package]]
name = "indexmap"
version = "1.9.2"
@ -1511,6 +1521,15 @@ dependencies = [
"phf_shared 0.10.0",
]
[[package]]
name = "phf"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928c6535de93548188ef63bb7c4036bd415cd8f36ad25af44b9789b2ee72a48c"
dependencies = [
"phf_shared 0.11.1",
]
[[package]]
name = "phf_codegen"
version = "0.8.0"
@ -1569,6 +1588,15 @@ dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1fb5f6f826b772a8d4c0394209441e7d37cbbb967ae9c7e0e8134365c9ee676"
dependencies = [
"siphasher",
]
[[package]]
name = "pico-args"
version = "0.4.2"
@ -1923,6 +1951,7 @@ dependencies = [
"glob",
"globset",
"ignore",
"imperative",
"insta",
"itertools",
"js-sys",
@ -2027,6 +2056,16 @@ dependencies = [
"textwrap",
]
[[package]]
name = "rust-stemmers"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54"
dependencies = [
"serde",
"serde_derive",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"

View file

@ -35,6 +35,7 @@ fern = { version = "0.6.1" }
glob = { version = "0.3.0" }
globset = { version = "0.4.9" }
ignore = { version = "0.4.18" }
imperative = { version = "1.0.3" }
itertools = { version = "0.10.5" }
libcst = { git = "https://github.com/charliermarsh/LibCST", rev = "f2f0b7a487a8725d161fe8b3ed73a6758b21e177" }
log = { version = "0.4.17" }
@ -59,9 +60,9 @@ smallvec = { version = "1.10.0" }
strum = { version = "0.24.1", features = ["strum_macros"] }
strum_macros = { version = "0.24.3" }
textwrap = { version = "0.16.0" }
thiserror = { version = "1.0" }
titlecase = { version = "2.2.1" }
toml_edit = { version = "0.17.1", features = ["easy"] }
thiserror = { version = "1.0" }
# https://docs.rs/getrandom/0.2.7/getrandom/#webassembly-support
# For (future) wasm-pack support

View file

@ -672,6 +672,7 @@ For more, see [pydocstyle](https://pypi.org/project/pydocstyle/6.1.1/) on PyPI.
| D300 | uses-triple-quotes | Use """triple double quotes""" | |
| D301 | uses-r-prefix-for-backslashed-content | Use r""" if any backslashes in a docstring | |
| D400 | ends-in-period | First line should end with a period | 🛠 |
| D401 | non-imperative-mood | First line of docstring should be in imperative mood: "{first_line}" | |
| D402 | no-signature | First line should not be the function's signature | |
| D403 | first-line-capitalized | First word of the first line should be properly capitalized | |
| D404 | no-this-prefix | First word of the docstring should not be "This" | |

View file

@ -0,0 +1,43 @@
# Bad examples
def bad_liouiwnlkjl():
"""Returns foo."""
def bad_sdgfsdg23245():
"""Constructor for a foo."""
def bad_sdgfsdg23245777():
"""
Constructor for a boa.
"""
def bad_run_something():
"""Runs something"""
pass
def multi_line():
"""Writes a logical line that
extends to two physical lines.
"""
# Good examples
def good_run_something():
"""Run away."""
def good_construct():
"""Construct a beautiful house."""
def good_multi_line():
"""Write a logical line that
extends to two physical lines.
"""

View file

@ -1277,6 +1277,7 @@
"D4",
"D40",
"D400",
"D401",
"D402",
"D403",
"D404",

View file

@ -4497,6 +4497,7 @@ impl<'a> Checker<'a> {
.rules
.enabled(&Rule::UsesRPrefixForBackslashedContent)
|| self.settings.rules.enabled(&Rule::EndsInPeriod)
|| self.settings.rules.enabled(&Rule::NonImperativeMood)
|| self.settings.rules.enabled(&Rule::NoSignature)
|| self.settings.rules.enabled(&Rule::FirstLineCapitalized)
|| self.settings.rules.enabled(&Rule::NoThisPrefix)
@ -4645,6 +4646,9 @@ impl<'a> Checker<'a> {
if self.settings.rules.enabled(&Rule::EndsInPeriod) {
pydocstyle::rules::ends_with_period(self, &docstring);
}
if self.settings.rules.enabled(&Rule::NonImperativeMood) {
pydocstyle::rules::non_imperative_mood::non_imperative_mood(self, &docstring);
}
if self.settings.rules.enabled(&Rule::NoSignature) {
pydocstyle::rules::no_signature(self, &docstring);
}

View file

@ -281,6 +281,7 @@ ruff_macros::define_rule_mapping!(
D300 => violations::UsesTripleQuotes,
D301 => violations::UsesRPrefixForBackslashedContent,
D400 => violations::EndsInPeriod,
D401 => crate::rules::pydocstyle::rules::non_imperative_mood::NonImperativeMood,
D402 => violations::NoSignature,
D403 => violations::FirstLineCapitalized,
D404 => violations::NoThisPrefix,

View file

@ -55,3 +55,11 @@ pub fn logical_line(content: &str) -> Option<usize> {
}
logical_line
}
/// Normalize a word by removing all non-alphanumeric characters
/// and converting it to lowercase.
pub fn normalize_word(first_word: &str) -> String {
first_word
.replace(|c: char| !c.is_alphanumeric(), "")
.to_lowercase()
}

View file

@ -44,6 +44,7 @@ mod tests {
#[test_case(Rule::UsesRPrefixForBackslashedContent, Path::new("D.py"); "D301")]
#[test_case(Rule::EndsInPeriod, Path::new("D.py"); "D400_0")]
#[test_case(Rule::EndsInPeriod, Path::new("D400.py"); "D400_1")]
#[test_case(Rule::NonImperativeMood, Path::new("D401.py"); "D401")]
#[test_case(Rule::NoSignature, Path::new("D.py"); "D402")]
#[test_case(Rule::FirstLineCapitalized, Path::new("D.py"); "D403")]
#[test_case(Rule::NoThisPrefix, Path::new("D.py"); "D404")]

View file

@ -31,6 +31,7 @@ mod multi_line_summary_start;
mod newline_after_last_paragraph;
mod no_signature;
mod no_surrounding_whitespace;
pub mod non_imperative_mood;
mod not_empty;
mod not_missing;
mod one_liner;

View file

@ -0,0 +1,50 @@
use imperative::Mood;
use once_cell::sync::Lazy;
use ruff_macros::derive_message_formats;
use crate::ast::types::Range;
use crate::checkers::ast::Checker;
use crate::define_violation;
use crate::docstrings::definition::Docstring;
use crate::registry::Diagnostic;
use crate::rules::pydocstyle::helpers::normalize_word;
use crate::violation::Violation;
static MOOD: Lazy<Mood> = Lazy::new(Mood::new);
/// D401
pub fn non_imperative_mood(checker: &mut Checker, docstring: &Docstring) {
let body = docstring.body;
// Find first line, disregarding whitespace.
let line = match body.trim().lines().next() {
Some(line) => line.trim(),
None => return,
};
// Find the first word on that line and normalize it to lower-case.
let first_word_norm = match line.split_whitespace().next() {
Some(word) => normalize_word(word),
None => return,
};
if first_word_norm.is_empty() {
return;
}
if let Some(false) = MOOD.is_imperative(&first_word_norm) {
let diagnostic = Diagnostic::new(
NonImperativeMood(line.to_string()),
Range::from_located(docstring.expr),
);
checker.diagnostics.push(diagnostic);
}
}
define_violation!(
pub struct NonImperativeMood(pub String);
);
impl Violation for NonImperativeMood {
#[derive_message_formats]
fn message(&self) -> String {
let NonImperativeMood(first_line) = self;
format!("First line of docstring should be in imperative mood: \"{first_line}\"")
}
}

View file

@ -2,6 +2,7 @@ use crate::ast::types::Range;
use crate::checkers::ast::Checker;
use crate::docstrings::definition::Docstring;
use crate::registry::Diagnostic;
use crate::rules::pydocstyle::helpers::normalize_word;
use crate::violations;
/// D404
@ -16,11 +17,7 @@ pub fn starts_with_this(checker: &mut Checker, docstring: &Docstring) {
let Some(first_word) = body.split(' ').next() else {
return
};
if first_word
.replace(|c: char| !c.is_alphanumeric(), "")
.to_lowercase()
!= "this"
{
if normalize_word(first_word) != "this" {
return;
}
checker.diagnostics.push(Diagnostic::new(

View file

@ -0,0 +1,55 @@
---
source: src/rules/pydocstyle/mod.rs
expression: diagnostics
---
- kind:
NonImperativeMood: Returns foo.
location:
row: 4
column: 4
end_location:
row: 4
column: 22
fix: ~
parent: ~
- kind:
NonImperativeMood: Constructor for a foo.
location:
row: 8
column: 4
end_location:
row: 8
column: 32
fix: ~
parent: ~
- kind:
NonImperativeMood: Constructor for a boa.
location:
row: 12
column: 4
end_location:
row: 16
column: 7
fix: ~
parent: ~
- kind:
NonImperativeMood: Runs something
location:
row: 20
column: 4
end_location:
row: 20
column: 24
fix: ~
parent: ~
- kind:
NonImperativeMood: Writes a logical line that
location:
row: 25
column: 4
end_location:
row: 27
column: 7
fix: ~
parent: ~