Skip to content

Commit 48a2590

Browse files
feat: introduce a doc_comments_missing_terminal_punctuation lint
1 parent 3e0590c commit 48a2590

9 files changed

+632
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6066,6 +6066,7 @@ Released 2018-09-13
60666066
[`diverging_sub_expression`]: https://rust-lang.github.io/rust-clippy/master/index.html#diverging_sub_expression
60676067
[`doc_broken_link`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_broken_link
60686068
[`doc_comment_double_space_linebreaks`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_comment_double_space_linebreaks
6069+
[`doc_comments_missing_terminal_punctuation`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_comments_missing_terminal_punctuation
60696070
[`doc_include_without_cfg`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_include_without_cfg
60706071
[`doc_lazy_continuation`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_lazy_continuation
60716072
[`doc_link_code`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_link_code

clippy_lints/src/declared_lints.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ pub static LINTS: &[&::declare_clippy_lint::LintInfo] = &[
114114
crate::disallowed_script_idents::DISALLOWED_SCRIPT_IDENTS_INFO,
115115
crate::disallowed_types::DISALLOWED_TYPES_INFO,
116116
crate::doc::DOC_BROKEN_LINK_INFO,
117+
crate::doc::DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION_INFO,
117118
crate::doc::DOC_COMMENT_DOUBLE_SPACE_LINEBREAKS_INFO,
118119
crate::doc::DOC_INCLUDE_WITHOUT_CFG_INFO,
119120
crate::doc::DOC_LAZY_CONTINUATION_INFO,
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
use rustc_ast::ast::{AttrKind, AttrStyle, Attribute};
2+
use rustc_errors::Applicability;
3+
use rustc_lint::EarlyContext;
4+
5+
use super::DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION;
6+
7+
const MSG: &str = "doc comments should end with a terminal punctuation mark";
8+
const PUNCTUATION_SUGGESTION: char = '.';
9+
10+
pub fn check(cx: &EarlyContext<'_>, attrs: &[Attribute]) {
11+
let mut doc_comment_attrs = attrs.iter().enumerate().filter(|(_, a)| is_doc_comment(a));
12+
13+
let Some((i, mut last_doc_attr)) = doc_comment_attrs.next_back() else {
14+
return;
15+
};
16+
17+
// Check that the next attribute is not a `#[doc]` attribute.
18+
if let Some(next_attr) = attrs.get(i + 1)
19+
&& is_doc_attr(next_attr)
20+
{
21+
return;
22+
}
23+
24+
// Find the last, non-blank, non-refdef line of multiline doc comments: this is enough to check that
25+
// the doc comment ends with proper punctuation.
26+
while is_doc_comment_trailer(last_doc_attr) {
27+
if let Some(doc_attr) = doc_comment_attrs.next_back() {
28+
(_, last_doc_attr) = doc_attr;
29+
} else {
30+
// The doc comment looks (functionally) empty.
31+
return;
32+
}
33+
}
34+
35+
if let Some(doc_string) = is_missing_punctuation(last_doc_attr) {
36+
let span = last_doc_attr.span;
37+
38+
if is_line_doc_comment(last_doc_attr) {
39+
let suggestion = generate_suggestion(last_doc_attr, doc_string);
40+
41+
clippy_utils::diagnostics::span_lint_and_sugg(
42+
cx,
43+
DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION,
44+
span,
45+
MSG,
46+
"end the doc comment with some punctuation",
47+
suggestion,
48+
Applicability::MaybeIncorrect,
49+
);
50+
} else {
51+
// Seems more difficult to preserve the formatting of block doc comments, so we do not provide
52+
// suggestions for them; they are much rarer anyway.
53+
clippy_utils::diagnostics::span_lint(cx, DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION, span, MSG);
54+
}
55+
}
56+
}
57+
58+
#[must_use]
59+
fn is_missing_punctuation(attr: &Attribute) -> Option<&str> {
60+
const TERMINAL_PUNCTUATION_MARKS: &[char] = &['.', '?', '!', '…'];
61+
const EXCEPTIONS: &[char] = &[
62+
'>', // Raw HTML or (unfortunately) Markdown autolinks.
63+
'|', // Markdown tables.
64+
];
65+
66+
let doc_string = get_doc_string(attr)?;
67+
68+
// Doc comments could have some trailing whitespace, but that is not this lint's job.
69+
let trimmed = doc_string.trim_end();
70+
71+
// Doc comments are also allowed to end with fenced code blocks.
72+
if trimmed.ends_with(TERMINAL_PUNCTUATION_MARKS) || trimmed.ends_with(EXCEPTIONS) || trimmed.ends_with("```") {
73+
return None;
74+
}
75+
76+
// Ignore single-line list items: they may not require any terminal punctuation.
77+
if looks_like_list_item(trimmed) {
78+
return None;
79+
}
80+
81+
if let Some(stripped) = strip_sentence_trailers(trimmed)
82+
&& stripped.ends_with(TERMINAL_PUNCTUATION_MARKS)
83+
{
84+
return None;
85+
}
86+
87+
Some(doc_string)
88+
}
89+
90+
#[must_use]
91+
fn generate_suggestion(doc_attr: &Attribute, doc_string: &str) -> String {
92+
let doc_comment_prefix = match doc_attr.style {
93+
AttrStyle::Outer => "///",
94+
AttrStyle::Inner => "//!",
95+
};
96+
97+
let mut original_line = format!("{doc_comment_prefix}{doc_string}");
98+
99+
if let Some(stripped) = strip_sentence_trailers(doc_string) {
100+
// Insert the punctuation mark just before the sentence trailer.
101+
original_line.insert(doc_comment_prefix.len() + stripped.len(), PUNCTUATION_SUGGESTION);
102+
} else {
103+
original_line.push(PUNCTUATION_SUGGESTION);
104+
}
105+
106+
original_line
107+
}
108+
109+
/// Strips closing parentheses and Markdown emphasis delimiters.
110+
#[must_use]
111+
fn strip_sentence_trailers(string: &str) -> Option<&str> {
112+
// The std has a few occurrences of doc comments ending with a sentence in parentheses.
113+
const TRAILERS: &[char] = &[')', '*', '_'];
114+
115+
if let Some(stripped) = string.strip_suffix("**") {
116+
return Some(stripped);
117+
}
118+
119+
if let Some(stripped) = string.strip_suffix("__") {
120+
return Some(stripped);
121+
}
122+
123+
// Markdown inline links should not be mistaken for sentences in parentheses.
124+
if looks_like_inline_link(string) {
125+
return None;
126+
}
127+
128+
string.strip_suffix(TRAILERS)
129+
}
130+
131+
/// Returns whether the doc comment looks like a Markdown reference definition or a blank line.
132+
#[must_use]
133+
fn is_doc_comment_trailer(attr: &Attribute) -> bool {
134+
let Some(doc_string) = get_doc_string(attr) else {
135+
return false;
136+
};
137+
138+
super::looks_like_refdef(doc_string, 0..doc_string.len()).is_some() || doc_string.trim_end().is_empty()
139+
}
140+
141+
/// Returns whether the string looks like it ends with a Markdown inline link.
142+
#[must_use]
143+
fn looks_like_inline_link(string: &str) -> bool {
144+
let Some(sub) = string.strip_suffix(')') else {
145+
return false;
146+
};
147+
let Some((sub, _)) = sub.rsplit_once('(') else {
148+
return false;
149+
};
150+
151+
// Check whether there is closing bracket just before the opening parenthesis.
152+
sub.ends_with(']')
153+
}
154+
155+
/// Returns whether the string looks like a Markdown list item.
156+
#[must_use]
157+
fn looks_like_list_item(string: &str) -> bool {
158+
const BULLET_LIST_MARKERS: &[char] = &['-', '+', '*'];
159+
const ORDERED_LIST_MARKER_SYMBOL: &[char] = &['.', ')'];
160+
161+
let trimmed = string.trim_start();
162+
163+
if let Some(sub) = trimmed.strip_prefix(BULLET_LIST_MARKERS)
164+
&& sub.starts_with(char::is_whitespace)
165+
{
166+
return true;
167+
}
168+
169+
let mut stripped = trimmed;
170+
while let Some(sub) = stripped.strip_prefix(|c| char::is_digit(c, 10)) {
171+
stripped = sub;
172+
}
173+
if let Some(sub) = stripped.strip_prefix(ORDERED_LIST_MARKER_SYMBOL)
174+
&& sub.starts_with(char::is_whitespace)
175+
{
176+
return true;
177+
}
178+
179+
false
180+
}
181+
182+
#[must_use]
183+
fn is_doc_attr(attr: &Attribute) -> bool {
184+
if let AttrKind::Normal(normal_attr) = &attr.kind
185+
&& let Some(segment) = &normal_attr.item.path.segments.first()
186+
&& segment.ident.name.as_str() == "doc"
187+
{
188+
true
189+
} else {
190+
false
191+
}
192+
}
193+
194+
#[must_use]
195+
fn get_doc_string(attr: &Attribute) -> Option<&str> {
196+
if let AttrKind::DocComment(_, symbol) = &attr.kind {
197+
Some(symbol.as_str())
198+
} else {
199+
None
200+
}
201+
}
202+
203+
#[must_use]
204+
fn is_doc_comment(attr: &Attribute) -> bool {
205+
matches!(attr.kind, AttrKind::DocComment(_, _))
206+
}
207+
208+
#[must_use]
209+
fn is_line_doc_comment(attr: &Attribute) -> bool {
210+
matches!(attr.kind, AttrKind::DocComment(rustc_ast::token::CommentKind::Line, _))
211+
}

clippy_lints/src/doc/mod.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ use url::Url;
2626

2727
mod broken_link;
2828
mod doc_comment_double_space_linebreaks;
29+
mod doc_comments_missing_terminal_punctuation;
2930
mod doc_suspicious_footnotes;
3031
mod include_in_doc_without_cfg;
3132
mod lazy_continuation;
@@ -668,6 +669,28 @@ declare_clippy_lint! {
668669
"looks like a link or footnote ref, but with no definition"
669670
}
670671

672+
declare_clippy_lint! {
673+
/// ### What it does
674+
/// Check for doc comments that do not end with a period or another punctuation mark.
675+
/// Various Markdowns constructs are taken into account to avoid false positives.
676+
///
677+
/// ### Why is this bad?
678+
/// A project may wish to enforce consistent doc comments by making sure they end with a punctuation mark.
679+
///
680+
/// ### Example
681+
/// ```no_run
682+
/// /// Returns the Answer to the Ultimate Question of Life, the Universe, and Everything
683+
/// ```
684+
/// Use instead:
685+
/// ```no_run
686+
/// /// Returns the Answer to the Ultimate Question of Life, the Universe, and Everything.
687+
/// ```
688+
#[clippy::version = "1.92.0"]
689+
pub DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION,
690+
nursery,
691+
"missing terminal punctuation in doc comments"
692+
}
693+
671694
pub struct Documentation {
672695
valid_idents: FxHashSet<String>,
673696
check_private_items: bool,
@@ -702,11 +725,13 @@ impl_lint_pass!(Documentation => [
702725
DOC_INCLUDE_WITHOUT_CFG,
703726
DOC_COMMENT_DOUBLE_SPACE_LINEBREAKS,
704727
DOC_SUSPICIOUS_FOOTNOTES,
728+
DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION,
705729
]);
706730

707731
impl EarlyLintPass for Documentation {
708732
fn check_attributes(&mut self, cx: &EarlyContext<'_>, attrs: &[rustc_ast::Attribute]) {
709733
include_in_doc_without_cfg::check(cx, attrs);
734+
doc_comments_missing_terminal_punctuation::check(cx, attrs);
710735
}
711736
}
712737

0 commit comments

Comments
 (0)