From f49a91ce33bff70b0165f2920be2058882a7ec9f Mon Sep 17 00:00:00 2001 From: Guillaume Gomez Date: Sat, 12 Jul 2025 15:45:59 +0200 Subject: [PATCH 1/2] Add new `comment_within_doc` lint --- CHANGELOG.md | 1 + clippy_lints/src/declared_lints.rs | 1 + clippy_lints/src/doc/comment_within_doc.rs | 107 +++++++++++++++++++++ clippy_lints/src/doc/mod.rs | 28 ++++++ 4 files changed, 137 insertions(+) create mode 100644 clippy_lints/src/doc/comment_within_doc.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a92fbdc767bd..538de00e5985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5779,6 +5779,7 @@ Released 2018-09-13 [`collapsible_match`]: https://rust-lang.github.io/rust-clippy/master/index.html#collapsible_match [`collapsible_str_replace`]: https://rust-lang.github.io/rust-clippy/master/index.html#collapsible_str_replace [`collection_is_never_read`]: https://rust-lang.github.io/rust-clippy/master/index.html#collection_is_never_read +[`comment_within_doc`]: https://rust-lang.github.io/rust-clippy/master/index.html#comment_within_doc [`comparison_chain`]: https://rust-lang.github.io/rust-clippy/master/index.html#comparison_chain [`comparison_to_empty`]: https://rust-lang.github.io/rust-clippy/master/index.html#comparison_to_empty [`confusing_method_to_numeric_cast`]: https://rust-lang.github.io/rust-clippy/master/index.html#confusing_method_to_numeric_cast diff --git a/clippy_lints/src/declared_lints.rs b/clippy_lints/src/declared_lints.rs index c3f8e02b4c06..7a008864ae77 100644 --- a/clippy_lints/src/declared_lints.rs +++ b/clippy_lints/src/declared_lints.rs @@ -112,6 +112,7 @@ pub static LINTS: &[&::declare_clippy_lint::LintInfo] = &[ crate::disallowed_names::DISALLOWED_NAMES_INFO, crate::disallowed_script_idents::DISALLOWED_SCRIPT_IDENTS_INFO, crate::disallowed_types::DISALLOWED_TYPES_INFO, + crate::doc::COMMENT_WITHIN_DOC_INFO, crate::doc::DOC_BROKEN_LINK_INFO, crate::doc::DOC_COMMENT_DOUBLE_SPACE_LINEBREAKS_INFO, crate::doc::DOC_INCLUDE_WITHOUT_CFG_INFO, diff --git a/clippy_lints/src/doc/comment_within_doc.rs b/clippy_lints/src/doc/comment_within_doc.rs new file mode 100644 index 000000000000..a23dce5b2d49 --- /dev/null +++ b/clippy_lints/src/doc/comment_within_doc.rs @@ -0,0 +1,107 @@ +use rustc_ast::token::CommentKind; +use rustc_ast::{AttrKind, AttrStyle}; +use rustc_errors::Applicability; +use rustc_lexer::{TokenKind, tokenize}; +use rustc_lint::{EarlyContext, LintContext}; +use rustc_span::source_map::SourceMap; +use rustc_span::{BytePos, Span}; + +use clippy_utils::diagnostics::span_lint_and_then; + +use super::COMMENT_WITHIN_DOC; + +struct AttrInfo { + line: usize, + is_outer: bool, + span: Span, + file_span_pos: BytePos, +} + +impl AttrInfo { + fn new(source_map: &SourceMap, attr: &rustc_ast::Attribute) -> Option { + let span_info = source_map.span_to_lines(attr.span).ok()?; + // If we cannot get the line for any reason, no point in building this item. + let line = span_info.lines.last()?.line_index; + Some(Self { + line, + is_outer: attr.style == AttrStyle::Outer, + span: attr.span, + file_span_pos: span_info.file.start_pos, + }) + } +} + +// Returns a `Vec` of `TokenKind` if the span only contains comments, otherwise returns `None`. +fn snippet_contains_only_comments(snippet: &str) -> Option> { + let mut tokens = Vec::new(); + for token in tokenize(snippet) { + match token.kind { + TokenKind::Whitespace => {}, + TokenKind::BlockComment { .. } | TokenKind::LineComment { .. } => tokens.push(token.kind), + _ => return None, + } + } + Some(tokens) +} + +pub(super) fn check(cx: &EarlyContext<'_>, attrs: &[rustc_ast::Attribute]) { + let mut stored_prev_attr = None; + let source_map = cx.sess().source_map(); + for attr in attrs + .iter() + // We ignore `#[doc = "..."]` and `/** */` attributes. + .filter(|attr| matches!(attr.kind, AttrKind::DocComment(CommentKind::Line, _))) + { + let Some(attr) = AttrInfo::new(source_map, attr) else { + stored_prev_attr = None; + continue; + }; + let Some(prev_attr) = stored_prev_attr else { + stored_prev_attr = Some(attr); + continue; + }; + // First we check if they are from the same file and if they are the same kind of doc + // comments. + if attr.file_span_pos != prev_attr.file_span_pos || attr.is_outer != prev_attr.is_outer { + stored_prev_attr = Some(attr); + continue; + } + let diff = attr.line - (prev_attr.line + 1); + // Then we check if they follow each other. + if diff == 0 || diff > 1 { + // If there is no line between them or there are more than 1, we skip this check. + stored_prev_attr = Some(attr); + continue; + } + let span_between = prev_attr.span.between(attr.span); + // If there is one line between the two doc comments and it contains only one line comment, + // then we lint. + if diff == 1 + && let Ok(snippet) = source_map.span_to_snippet(span_between) + && let Some(comments) = snippet_contains_only_comments(&snippet) + && let &[TokenKind::LineComment { .. }] = comments.as_slice() + { + let offset_begin = snippet.trim_start().len(); + let offset_end = snippet.trim_end().len(); + let span = span_between + .with_hi(span_between.lo() + BytePos(offset_begin.try_into().unwrap())) + .with_hi(span_between.hi() - BytePos(offset_end.try_into().unwrap())); + let comment_kind = if attr.is_outer { '/' } else { '!' }; + span_lint_and_then( + cx, + COMMENT_WITHIN_DOC, + span, + "code comment surrounded by doc comments", + |diag| { + diag.span_suggestion( + span.with_hi(span.lo() + BytePos(2)), + "did you mean to make it a doc comment?", + format!("//{comment_kind}"), + Applicability::MaybeIncorrect, + ); + }, + ); + } + stored_prev_attr = Some(attr); + } +} diff --git a/clippy_lints/src/doc/mod.rs b/clippy_lints/src/doc/mod.rs index 2bf52216b832..6a067be793d3 100644 --- a/clippy_lints/src/doc/mod.rs +++ b/clippy_lints/src/doc/mod.rs @@ -25,6 +25,7 @@ use std::ops::Range; use url::Url; mod broken_link; +mod comment_within_doc; mod doc_comment_double_space_linebreaks; mod doc_suspicious_footnotes; mod include_in_doc_without_cfg; @@ -668,6 +669,31 @@ declare_clippy_lint! { "looks like a link or footnote ref, but with no definition" } +declare_clippy_lint! { + /// ### What it does + /// Checks if a code comment is surrounded by doc comments. + /// + /// ### Why is this bad? + /// This is likely a typo, making the documentation miss a line. + /// + /// ### Example + /// ```no_run + /// //! Doc + /// // oups + /// //! doc + /// ``` + /// Use instead: + /// ```no_run + /// //! Doc + /// //! oups + /// //! doc + /// ``` + #[clippy::version = "1.90.0"] + pub COMMENT_WITHIN_DOC, + pedantic, + "code comment surrounded by doc comments" +} + pub struct Documentation { valid_idents: FxHashSet, check_private_items: bool, @@ -702,11 +728,13 @@ impl_lint_pass!(Documentation => [ DOC_INCLUDE_WITHOUT_CFG, DOC_COMMENT_DOUBLE_SPACE_LINEBREAKS, DOC_SUSPICIOUS_FOOTNOTES, + COMMENT_WITHIN_DOC, ]); impl EarlyLintPass for Documentation { fn check_attributes(&mut self, cx: &EarlyContext<'_>, attrs: &[rustc_ast::Attribute]) { include_in_doc_without_cfg::check(cx, attrs); + comment_within_doc::check(cx, attrs); } } From 938e6478dc639e8a6f334995a673850594d51289 Mon Sep 17 00:00:00 2001 From: Guillaume Gomez Date: Sat, 12 Jul 2025 15:46:27 +0200 Subject: [PATCH 2/2] Add test for new `comment_within_doc` lint --- tests/ui/comment_within_doc.fixed | 23 ++++++++++++++ tests/ui/comment_within_doc.rs | 27 ++++++++++++++++ tests/ui/comment_within_doc.stderr | 51 ++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 tests/ui/comment_within_doc.fixed create mode 100644 tests/ui/comment_within_doc.rs create mode 100644 tests/ui/comment_within_doc.stderr diff --git a/tests/ui/comment_within_doc.fixed b/tests/ui/comment_within_doc.fixed new file mode 100644 index 000000000000..ab0039558e6a --- /dev/null +++ b/tests/ui/comment_within_doc.fixed @@ -0,0 +1,23 @@ +#![warn(clippy::comment_within_doc)] + +//! Hello//!/ oups +//! tadam +//~^^^ comment_within_doc + +/// Hello//// oups +/// hehe +//~^^^ comment_within_doc +struct Bar; + +mod b { + //! targe//! // oups + //! hello + // + /// nope/// // oups + /// yep + //~^^^ comment_within_doc + //~^^^^^^^^ comment_within_doc + struct Bar; +} + +fn main() {} diff --git a/tests/ui/comment_within_doc.rs b/tests/ui/comment_within_doc.rs new file mode 100644 index 000000000000..3136ac752c8f --- /dev/null +++ b/tests/ui/comment_within_doc.rs @@ -0,0 +1,27 @@ +#![warn(clippy::comment_within_doc)] + +//! Hello +// oups +//! tadam +//~^^^ comment_within_doc + +/// Hello +// oups +/// hehe +//~^^^ comment_within_doc +struct Bar; + +mod b { + //! targe + // oups + //! hello + // + /// nope + // oups + /// yep + //~^^^ comment_within_doc + //~^^^^^^^^ comment_within_doc + struct Bar; +} + +fn main() {} diff --git a/tests/ui/comment_within_doc.stderr b/tests/ui/comment_within_doc.stderr new file mode 100644 index 000000000000..cd716c417e48 --- /dev/null +++ b/tests/ui/comment_within_doc.stderr @@ -0,0 +1,51 @@ +error: code comment surrounded by doc comments + --> tests/ui/comment_within_doc.rs:3:10 + | +LL | //! Hello + | __________^ + | |__________| +LL | || // oups + | || ^ + | ||_| + | |_help: did you mean to make it a doc comment?: `//!` + | + | + = note: `-D clippy::comment-within-doc` implied by `-D warnings` + = help: to override `-D warnings` add `#[allow(clippy::comment_within_doc)]` + +error: code comment surrounded by doc comments + --> tests/ui/comment_within_doc.rs:8:10 + | +LL | /// Hello + | __________^ + | |__________| +LL | || // oups + | || ^ + | ||_| + | |_help: did you mean to make it a doc comment?: `///` + | + +error: code comment surrounded by doc comments + --> tests/ui/comment_within_doc.rs:15:14 + | +LL | //! targe + | ______________^ + | |______________| +LL | || // oups + | ||_-__^ + | |__| + | help: did you mean to make it a doc comment?: `//!` + +error: code comment surrounded by doc comments + --> tests/ui/comment_within_doc.rs:19:13 + | +LL | /// nope + | _____________^ + | |_____________| +LL | || // oups + | ||_-__^ + | |__| + | help: did you mean to make it a doc comment?: `///` + +error: aborting due to 4 previous errors +