Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6066,6 +6066,7 @@ Released 2018-09-13
[`diverging_sub_expression`]: https://rust-lang.github.io/rust-clippy/master/index.html#diverging_sub_expression
[`doc_broken_link`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_broken_link
[`doc_comment_double_space_linebreaks`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_comment_double_space_linebreaks
[`doc_comments_missing_terminal_punctuation`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_comments_missing_terminal_punctuation
[`doc_include_without_cfg`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_include_without_cfg
[`doc_lazy_continuation`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_lazy_continuation
[`doc_link_code`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_link_code
Expand Down
1 change: 1 addition & 0 deletions clippy_lints/src/declared_lints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ pub static LINTS: &[&::declare_clippy_lint::LintInfo] = &[
crate::disallowed_script_idents::DISALLOWED_SCRIPT_IDENTS_INFO,
crate::disallowed_types::DISALLOWED_TYPES_INFO,
crate::doc::DOC_BROKEN_LINK_INFO,
crate::doc::DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION_INFO,
crate::doc::DOC_COMMENT_DOUBLE_SPACE_LINEBREAKS_INFO,
crate::doc::DOC_INCLUDE_WITHOUT_CFG_INFO,
crate::doc::DOC_LAZY_CONTINUATION_INFO,
Expand Down
117 changes: 117 additions & 0 deletions clippy_lints/src/doc/doc_comments_missing_terminal_punctuation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
use rustc_errors::Applicability;
use rustc_lint::LateContext;
use rustc_resolve::rustdoc::main_body_opts;

use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};

use super::{DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION, Fragments};

const MSG: &str = "doc comments should end with a terminal punctuation mark";
const PUNCTUATION_SUGGESTION: char = '.';

pub fn check(cx: &LateContext<'_>, doc: &str, fragments: Fragments<'_>) {
match is_missing_punctuation(doc) {
IsMissingPunctuation::Fixable(offset) => {
// This ignores `#[doc]` attributes, which we do not handle.
if let Some(span) = fragments.span(cx, offset..offset) {
clippy_utils::diagnostics::span_lint_and_sugg(
cx,
DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION,
span,
MSG,
"end the doc comment with some punctuation",
PUNCTUATION_SUGGESTION.to_string(),
Applicability::MaybeIncorrect,
);
}
},
IsMissingPunctuation::Unfixable(offset) => {
// This ignores `#[doc]` attributes, which we do not handle.
if let Some(span) = fragments.span(cx, offset..offset) {
clippy_utils::diagnostics::span_lint_and_help(
cx,
DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION,
span,
MSG,
None,
"end the doc comment with some punctuation",
);
}
},
IsMissingPunctuation::No => {},
}
}

#[must_use]
/// If punctuation is missing, returns the offset where new punctuation should be inserted.
fn is_missing_punctuation(doc_string: &str) -> IsMissingPunctuation {
const TERMINAL_PUNCTUATION_MARKS: &[char] = &['.', '?', '!', '…'];

let mut no_report_depth = 0;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding catching individual paragraphs, would it suffice to track the unconditional depth of start/end tags and check when it goes from 1 -> 0 due to a TagEnd::Paragraph?

let mut missing_punctuation = IsMissingPunctuation::No;
for (event, offset) in
Parser::new_ext(doc_string, main_body_opts() - Options::ENABLE_SMART_PUNCTUATION).into_offset_iter()
Comment on lines +52 to +53
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double parsing every doc comment is not ideal, could this be tweaked into a state machine struct that check_doc passes the individual events into?

{
match event {
Event::Start(
Tag::CodeBlock(..)
| Tag::FootnoteDefinition(_)
| Tag::Heading { .. }
| Tag::HtmlBlock
| Tag::List(..)
| Tag::Table(_),
) => {
no_report_depth += 1;
},
Event::End(TagEnd::FootnoteDefinition) => {
no_report_depth -= 1;
},
Event::End(
TagEnd::CodeBlock | TagEnd::Heading(_) | TagEnd::HtmlBlock | TagEnd::List(_) | TagEnd::Table,
) => {
no_report_depth -= 1;
missing_punctuation = IsMissingPunctuation::No;
},
Event::InlineHtml(_) | Event::Start(Tag::Image { .. }) | Event::End(TagEnd::Image) => {
missing_punctuation = IsMissingPunctuation::No;
},
Event::Code(..) | Event::Start(Tag::Link { .. }) | Event::End(TagEnd::Link)
if no_report_depth == 0 && !offset.is_empty() =>
{
if doc_string[..offset.end]
.trim_end()
.ends_with(TERMINAL_PUNCTUATION_MARKS)
{
missing_punctuation = IsMissingPunctuation::No;
} else {
missing_punctuation = IsMissingPunctuation::Fixable(offset.end);
}
},
Event::Text(..) if no_report_depth == 0 && !offset.is_empty() => {
let trimmed = doc_string[..offset.end].trim_end();
if trimmed.ends_with(TERMINAL_PUNCTUATION_MARKS) {
missing_punctuation = IsMissingPunctuation::No;
} else if let Some(t) = trimmed.strip_suffix(|c| c == ')' || c == '"') {
if t.ends_with(TERMINAL_PUNCTUATION_MARKS) {
// Avoid false positives.
missing_punctuation = IsMissingPunctuation::No;
} else {
missing_punctuation = IsMissingPunctuation::Unfixable(offset.end);
}
} else {
missing_punctuation = IsMissingPunctuation::Fixable(offset.end);
}
},
_ => {},
}
}

missing_punctuation
}

#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum IsMissingPunctuation {
Fixable(usize),
Unfixable(usize),
No,
}
33 changes: 33 additions & 0 deletions clippy_lints/src/doc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use url::Url;

mod broken_link;
mod doc_comment_double_space_linebreaks;
mod doc_comments_missing_terminal_punctuation;
mod doc_suspicious_footnotes;
mod include_in_doc_without_cfg;
mod lazy_continuation;
Expand Down Expand Up @@ -668,6 +669,28 @@ declare_clippy_lint! {
"looks like a link or footnote ref, but with no definition"
}

declare_clippy_lint! {
/// ### What it does
/// Checks for doc comments that do not end with a period or another punctuation mark.
/// Various Markdowns constructs are taken into account to avoid false positives.
///
/// ### Why is this bad?
/// A project may wish to enforce consistent doc comments by making sure they end with a punctuation mark.
///
/// ### Example
/// ```no_run
/// /// Returns the Answer to the Ultimate Question of Life, the Universe, and Everything
/// ```
/// Use instead:
/// ```no_run
/// /// Returns the Answer to the Ultimate Question of Life, the Universe, and Everything.
/// ```
#[clippy::version = "1.92.0"]
pub DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION,
nursery,
"missing terminal punctuation in doc comments"
}

pub struct Documentation {
valid_idents: FxHashSet<String>,
check_private_items: bool,
Expand Down Expand Up @@ -702,6 +725,7 @@ impl_lint_pass!(Documentation => [
DOC_INCLUDE_WITHOUT_CFG,
DOC_COMMENT_DOUBLE_SPACE_LINEBREAKS,
DOC_SUSPICIOUS_FOOTNOTES,
DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION,
]);

impl EarlyLintPass for Documentation {
Expand Down Expand Up @@ -873,6 +897,15 @@ fn check_attrs(cx: &LateContext<'_>, valid_idents: &FxHashSet<String>, attrs: &[
},
);

doc_comments_missing_terminal_punctuation::check(
cx,
&doc,
Fragments {
doc: &doc,
fragments: &fragments,
},
);

// NOTE: check_doc uses it own cb function,
// to avoid causing duplicated diagnostics for the broken link checker.
let mut full_fake_broken_link_callback = |bl: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
Expand Down
166 changes: 166 additions & 0 deletions tests/ui/doc/doc_comments_missing_terminal_punctuation.fixed
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
#![feature(custom_inner_attributes)]
#![rustfmt::skip]
#![warn(clippy::doc_comments_missing_terminal_punctuation)]

/// Returns the Answer to the Ultimate Question of Life, the Universe, and Everything.
//~^ doc_comments_missing_terminal_punctuation
fn answer() -> i32 {
42
}

/// The `Option` type.
//~^ doc_comments_missing_terminal_punctuation
// Triggers even in the presence of another attribute.
#[derive(Debug)]
enum MyOption<T> {
/// No value.
//~^ doc_comments_missing_terminal_punctuation
None,
/// Some value of type `T`.
Some(T),
}

// Triggers correctly even when interleaved with other attributes.
/// A multiline
#[derive(Debug)]
/// doc comment:
/// only the last line triggers the lint.
//~^ doc_comments_missing_terminal_punctuation
enum Exceptions {
/// Question marks are fine?
QuestionMark,
/// Exclamation marks are fine!
ExclamationMark,
/// Ellipses are ok too…
Ellipsis,
/// HTML content is however not checked:
/// <em>Raw HTML is allowed as well</em>
RawHtml,
/// The raw HTML exception actually does the right thing to autolinks:
/// <https://spec.commonmark.org/0.31.2/#autolinks>.
//~^ doc_comments_missing_terminal_punctuation
MarkdownAutolink,
/// This table introduction ends with a colon:
///
/// | Exception | Note |
/// | -------------- | ----- |
/// | Markdown table | A-ok |
MarkdownTable,
/// Here is a snippet
///
/// ```
/// // Code blocks are no issues.
/// ```
CodeBlock,
}

// Check the lint can be expected on a whole enum at once.
#[expect(clippy::doc_comments_missing_terminal_punctuation)]
enum Char {
/// U+0000
Null,
/// U+0001
StartOfHeading,
}

// Check the lint can be expected on a single variant without affecting others.
enum Char2 {
#[expect(clippy::doc_comments_missing_terminal_punctuation)]
/// U+0000
Null,
/// U+0001.
//~^ doc_comments_missing_terminal_punctuation
StartOfHeading,
}

mod module {
//! Works on
//! inner attributes too.
//~^ doc_comments_missing_terminal_punctuation
}

enum Trailers {
/// Sometimes the last sentence ends with parentheses (and that's ok).
ParensPassing,
/// (Sometimes the last sentence is in parentheses.)
SentenceInParensPassing,
/// **Sometimes the last sentence is in bold, and that's ok.**
DoubleStarPassing,
/// **But sometimes it is missing a period.**
//~^ doc_comments_missing_terminal_punctuation
DoubleStarFailing,
/// _Sometimes the last sentence is in italics, and that's ok._
UnderscorePassing,
/// _But sometimes it is missing a period._
//~^ doc_comments_missing_terminal_punctuation
UnderscoreFailing,
/// This comment ends with "a quote."
AmericanStyleQuotePassing,
/// This comment ends with "a quote".
BritishStyleQuotePassing,
}

/// Doc comments can end with an [inline link](#anchor).
//~^ doc_comments_missing_terminal_punctuation
struct InlineLink;

/// Some doc comments contain [link reference definitions][spec].
//~^ doc_comments_missing_terminal_punctuation
///
/// [spec]: https://spec.commonmark.org/0.31.2/#link-reference-definitions
struct LinkRefDefinition;

// List items do not always need to end with a period.
enum UnorderedLists {
/// This list has an introductory sentence:
///
/// - A list item
Dash,
/// + A list item
Plus,
/// * A list item
Star,
}

enum OrderedLists {
/// 1. A list item
Dot,
/// 42) A list item
Paren,
}

/// Doc comments with trailing blank lines are supported.
//~^ doc_comments_missing_terminal_punctuation
///
struct TrailingBlankLine;

/// The first paragraph is not checked
///
/// Other sentences are not either
/// Only the last sentence is.
//~^ doc_comments_missing_terminal_punctuation
struct OnlyLastSentence;

/// ```
struct IncompleteBlockCode;

/// This ends with a code `span`.
//~^ doc_comments_missing_terminal_punctuation
struct CodeSpan;

#[expect(clippy::empty_docs)]
///
struct EmptyDocComment;

/**
* Block doc comments work.
*
*/
//~^^^ doc_comments_missing_terminal_punctuation
struct BlockDocComment;

/// Sometimes a doc attribute is used for concatenation
/// ```
#[doc = ""]
/// ```
struct DocAttribute;
Loading