Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
88 changes: 88 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,88 @@
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<'_>) {
// This ignores `#[doc]` attributes, which we do not handle.
if let Some(offset) = is_missing_punctuation(doc)
&& 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,
);
}
}

#[must_use]
/// If punctuation is missing, returns the offset where new punctuation should be inserted.
fn is_missing_punctuation(doc_string: &str) -> Option<usize> {
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?

Copy link
Contributor

Choose a reason for hiding this comment

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

Unfortunately, the "rules" for what makes sense in the middle of a comment are different from the rules that make sense at the end. For example, here's a few crates where a paragraph ends in a colon:

Copy link
Member

Choose a reason for hiding this comment

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

I don't see that being a problem, it would no longer lint on a final paragraph ending in : but that doesn't seem like it would be common enough to need a lint

let mut text_offset = None;
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;
text_offset = None;
Copy link
Author

Choose a reason for hiding this comment

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

I've split this arm so text_offset gets overwritten if these constructs are at the end of the doc comments (and added some tests for these cases).

},
Event::InlineHtml(_) | Event::Start(Tag::Image { .. }) | Event::End(TagEnd::Image) => {
text_offset = None;
},
Event::Code(..) | Event::Start(Tag::Link { .. }) | Event::End(TagEnd::Link)
if no_report_depth == 0 && !offset.is_empty() =>
{
text_offset = Some(offset.end);
},
Event::Text(..) if no_report_depth == 0 && !offset.is_empty() => {
// American-style quotes require punctuation to be placed inside closing quotation marks.
if doc_string[..offset.end].trim_end().ends_with('"') {
Copy link
Contributor

Choose a reason for hiding this comment

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

Okay, so this code can be extended to handle parens with basically-trivial additional complexity, right?

Suggested change
if doc_string[..offset.end].trim_end().ends_with('"') {
if doc_string[..offset.end].trim_end().ends_with(|c| c == '"' || c == ')') {

Copy link
Author

Choose a reason for hiding this comment

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

Sure, but that means that the fix suggestion would always add a period inside the parentheses, while it is more likely that adding it outside would be the correct choice. That makes me realize that the suggestion for quotes would also arbitrarily favor the American style over the British one.

Maybe we should treat these cases (quotes and parentheses) as unfixable: detect when terminal punctuation is missing but give no suggestions?

Copy link
Author

Choose a reason for hiding this comment

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

To be honest I think we are overfocusing on edge cases right now: this lint (even without handling quotes and parentheses) would already be really useful for rolling out on new projects to ensure a consistent style, and we know that we will not be able to handle every little edge case without full-blown NLP anyway. These edge cases can easily be circumvented by simply slightly rephrasing (and I understand that some people do not like tools dictating how they write, but in this case this seems pretty minor).

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we should treat these cases (quotes and parentheses) as unfixable: detect when terminal punctuation is missing but give no suggestions?

Yes, I agree. That would be great!

Copy link
Contributor

Choose a reason for hiding this comment

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

To be honest I think we are overfocusing on edge cases right now: this lint (even without handling quotes and parentheses) would already be really useful for rolling out on new projects to ensure a consistent style, and we know that we will not be able to handle every little edge case without full-blown NLP anyway.

It's certainly true that we can't reach perfection without full AI. When we inevitably err, though, I'd prefer to err on the side of false negatives instead of false positives. We could even solve the parens and quotes problem by adding them as accepted terminating punctuation:

const TERMINAL_PUNCTUATION_MARKS: &[char] = &['.', '?', '!', '…', ')', '"', '\''];

The point is that we don't want to make people #[allow] the lint.

Copy link
Author

Choose a reason for hiding this comment

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

I've pushed an update that does the following:

  • Consider both .) and ). to be correct (thus there could be false negatives in
    the former case, but nothing we can do about it).
  • Consider both ." and ". to be correct (same).
  • Otherwise the lint triggers and offers help, but does not suggest a fix.

The implementation could likely use some refactoring, but we should agree on the
behavior before that.

I did not modify TERMINAL_PUNCTUATION_MARKS because that was causing issues with Markdown links, which also end with a parenthesis. I also did not do anything about single quotation marks, because my understanding is that those are used in British English and I would expect American style not be used in that case (and therefore no special treatment is needed).

text_offset = Some(offset.end - 1);
} else {
text_offset = Some(offset.end);
}
},
_ => {},
}
}

let text_offset = text_offset?;
if doc_string[..text_offset]
.trim_end()
.ends_with(TERMINAL_PUNCTUATION_MARKS)
{
None
} else {
Some(text_offset)
}
}
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
169 changes: 169 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,169 @@
#![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 doc comment ends with parentheses (like this).
//~^ doc_comments_missing_terminal_punctuation
EndsWithParens,
/// (Sometimes the last sentence is in parentheses, but there is no special treatment of this.).
//~^ doc_comments_missing_terminal_punctuation
ParensFailing,
/// **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."
QuotePassing,
/// This comment ends with "a quote."
//~^ doc_comments_missing_terminal_punctuation
QuoteFailing,
}

/// 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