about summary refs log tree commit diff
diff options
context:
space:
mode:
authory21 <30553356+y21@users.noreply.github.com>2023-11-15 22:38:50 +0100
committery21 <30553356+y21@users.noreply.github.com>2023-11-20 12:08:07 +0100
commit56cee3c58776d6af6e00fb035310b294392582b4 (patch)
tree252a7cdb1bfd96b2cf7283327bcbbdead7e8eb1c
parentedb720b199083f4107b858a8761648065bf38d86 (diff)
downloadrust-56cee3c58776d6af6e00fb035310b294392582b4.tar.gz
rust-56cee3c58776d6af6e00fb035310b294392582b4.zip
move `doc.rs` to its own subdirectory
-rw-r--r--clippy_lints/src/doc/link_with_quotes.rs20
-rw-r--r--clippy_lints/src/doc/markdown.rs109
-rw-r--r--clippy_lints/src/doc/missing_headers.rs84
-rw-r--r--clippy_lints/src/doc/mod.rs (renamed from clippy_lints/src/doc.rs)391
-rw-r--r--clippy_lints/src/doc/needless_doctest_main.rs100
-rw-r--r--clippy_lints/src/doc/suspicious_doc_comments.rs48
-rw-r--r--tests/ui/doc_link_with_quotes.rs6
-rw-r--r--tests/ui/doc_link_with_quotes.stderr8
8 files changed, 423 insertions, 343 deletions
diff --git a/clippy_lints/src/doc/link_with_quotes.rs b/clippy_lints/src/doc/link_with_quotes.rs
new file mode 100644
index 00000000000..01191e811b0
--- /dev/null
+++ b/clippy_lints/src/doc/link_with_quotes.rs
@@ -0,0 +1,20 @@
+use std::ops::Range;
+
+use clippy_utils::diagnostics::span_lint;
+use rustc_lint::LateContext;
+
+use super::{Fragments, DOC_LINK_WITH_QUOTES};
+
+pub fn check(cx: &LateContext<'_>, trimmed_text: &str, range: Range<usize>, fragments: Fragments<'_>) {
+    if ((trimmed_text.starts_with('\'') && trimmed_text.ends_with('\''))
+        || (trimmed_text.starts_with('"') && trimmed_text.ends_with('"')))
+        && let Some(span) = fragments.span(cx, range)
+    {
+        span_lint(
+            cx,
+            DOC_LINK_WITH_QUOTES,
+            span,
+            "possible intra-doc link using quotes instead of backticks",
+        );
+    }
+}
diff --git a/clippy_lints/src/doc/markdown.rs b/clippy_lints/src/doc/markdown.rs
new file mode 100644
index 00000000000..c0b11eb0dd1
--- /dev/null
+++ b/clippy_lints/src/doc/markdown.rs
@@ -0,0 +1,109 @@
+use clippy_utils::diagnostics::{span_lint, span_lint_and_then};
+use clippy_utils::source::snippet_with_applicability;
+use rustc_data_structures::fx::FxHashSet;
+use rustc_errors::{Applicability, SuggestionStyle};
+use rustc_lint::LateContext;
+use rustc_span::{BytePos, Pos, Span};
+use url::Url;
+
+use crate::doc::DOC_MARKDOWN;
+
+pub fn check(cx: &LateContext<'_>, valid_idents: &FxHashSet<String>, text: &str, span: Span) {
+    for word in text.split(|c: char| c.is_whitespace() || c == '\'') {
+        // Trim punctuation as in `some comment (see foo::bar).`
+        //                                                   ^^
+        // Or even as in `_foo bar_` which is emphasized. Also preserve `::` as a prefix/suffix.
+        let mut word = word.trim_matches(|c: char| !c.is_alphanumeric() && c != ':');
+
+        // Remove leading or trailing single `:` which may be part of a sentence.
+        if word.starts_with(':') && !word.starts_with("::") {
+            word = word.trim_start_matches(':');
+        }
+        if word.ends_with(':') && !word.ends_with("::") {
+            word = word.trim_end_matches(':');
+        }
+
+        if valid_idents.contains(word) || word.chars().all(|c| c == ':') {
+            continue;
+        }
+
+        // Adjust for the current word
+        let offset = word.as_ptr() as usize - text.as_ptr() as usize;
+        let span = Span::new(
+            span.lo() + BytePos::from_usize(offset),
+            span.lo() + BytePos::from_usize(offset + word.len()),
+            span.ctxt(),
+            span.parent(),
+        );
+
+        check_word(cx, word, span);
+    }
+}
+
+fn check_word(cx: &LateContext<'_>, word: &str, span: Span) {
+    /// Checks if a string is upper-camel-case, i.e., starts with an uppercase and
+    /// contains at least two uppercase letters (`Clippy` is ok) and one lower-case
+    /// letter (`NASA` is ok).
+    /// Plurals are also excluded (`IDs` is ok).
+    fn is_camel_case(s: &str) -> bool {
+        if s.starts_with(|c: char| c.is_ascii_digit() | c.is_ascii_lowercase()) {
+            return false;
+        }
+
+        let s = s.strip_suffix('s').unwrap_or(s);
+
+        s.chars().all(char::is_alphanumeric)
+            && s.chars().filter(|&c| c.is_uppercase()).take(2).count() > 1
+            && s.chars().filter(|&c| c.is_lowercase()).take(1).count() > 0
+    }
+
+    fn has_underscore(s: &str) -> bool {
+        s != "_" && !s.contains("\\_") && s.contains('_')
+    }
+
+    fn has_hyphen(s: &str) -> bool {
+        s != "-" && s.contains('-')
+    }
+
+    if let Ok(url) = Url::parse(word) {
+        // try to get around the fact that `foo::bar` parses as a valid URL
+        if !url.cannot_be_a_base() {
+            span_lint(
+                cx,
+                DOC_MARKDOWN,
+                span,
+                "you should put bare URLs between `<`/`>` or make a proper Markdown link",
+            );
+
+            return;
+        }
+    }
+
+    // We assume that mixed-case words are not meant to be put inside backticks. (Issue #2343)
+    if has_underscore(word) && has_hyphen(word) {
+        return;
+    }
+
+    if has_underscore(word) || word.contains("::") || is_camel_case(word) {
+        let mut applicability = Applicability::MachineApplicable;
+
+        span_lint_and_then(
+            cx,
+            DOC_MARKDOWN,
+            span,
+            "item in documentation is missing backticks",
+            |diag| {
+                let snippet = snippet_with_applicability(cx, span, "..", &mut applicability);
+                diag.span_suggestion_with_style(
+                    span,
+                    "try",
+                    format!("`{snippet}`"),
+                    applicability,
+                    // always show the suggestion in a separate line, since the
+                    // inline presentation adds another pair of backticks
+                    SuggestionStyle::ShowAlways,
+                );
+            },
+        );
+    }
+}
diff --git a/clippy_lints/src/doc/missing_headers.rs b/clippy_lints/src/doc/missing_headers.rs
new file mode 100644
index 00000000000..703a100ef65
--- /dev/null
+++ b/clippy_lints/src/doc/missing_headers.rs
@@ -0,0 +1,84 @@
+use clippy_utils::diagnostics::{span_lint, span_lint_and_note};
+use clippy_utils::ty::{implements_trait, is_type_diagnostic_item};
+use clippy_utils::{is_doc_hidden, return_ty};
+use rustc_hir::{BodyId, FnSig, OwnerId, Unsafety};
+use rustc_lint::LateContext;
+use rustc_middle::ty;
+use rustc_span::{sym, Span};
+
+use super::{DocHeaders, MISSING_ERRORS_DOC, MISSING_PANICS_DOC, MISSING_SAFETY_DOC, UNNECESSARY_SAFETY_DOC};
+
+pub fn check(
+    cx: &LateContext<'_>,
+    owner_id: OwnerId,
+    sig: &FnSig<'_>,
+    headers: DocHeaders,
+    body_id: Option<BodyId>,
+    panic_span: Option<Span>,
+) {
+    if !cx.effective_visibilities.is_exported(owner_id.def_id) {
+        return; // Private functions do not require doc comments
+    }
+
+    // do not lint if any parent has `#[doc(hidden)]` attribute (#7347)
+    if cx
+        .tcx
+        .hir()
+        .parent_iter(owner_id.into())
+        .any(|(id, _node)| is_doc_hidden(cx.tcx.hir().attrs(id)))
+    {
+        return;
+    }
+
+    let span = cx.tcx.def_span(owner_id);
+    match (headers.safety, sig.header.unsafety) {
+        (false, Unsafety::Unsafe) => span_lint(
+            cx,
+            MISSING_SAFETY_DOC,
+            span,
+            "unsafe function's docs miss `# Safety` section",
+        ),
+        (true, Unsafety::Normal) => span_lint(
+            cx,
+            UNNECESSARY_SAFETY_DOC,
+            span,
+            "safe function's docs have unnecessary `# Safety` section",
+        ),
+        _ => (),
+    }
+    if !headers.panics && panic_span.is_some() {
+        span_lint_and_note(
+            cx,
+            MISSING_PANICS_DOC,
+            span,
+            "docs for function which may panic missing `# Panics` section",
+            panic_span,
+            "first possible panic found here",
+        );
+    }
+    if !headers.errors {
+        if is_type_diagnostic_item(cx, return_ty(cx, owner_id), sym::Result) {
+            span_lint(
+                cx,
+                MISSING_ERRORS_DOC,
+                span,
+                "docs for function returning `Result` missing `# Errors` section",
+            );
+        } else if let Some(body_id) = body_id
+            && let Some(future) = cx.tcx.lang_items().future_trait()
+            && let typeck = cx.tcx.typeck_body(body_id)
+            && let body = cx.tcx.hir().body(body_id)
+            && let ret_ty = typeck.expr_ty(body.value)
+            && implements_trait(cx, ret_ty, future, &[])
+            && let ty::Coroutine(_, subs, _) = ret_ty.kind()
+            && is_type_diagnostic_item(cx, subs.as_coroutine().return_ty(), sym::Result)
+        {
+            span_lint(
+                cx,
+                MISSING_ERRORS_DOC,
+                span,
+                "docs for function returning `Result` missing `# Errors` section",
+            );
+        }
+    }
+}
diff --git a/clippy_lints/src/doc.rs b/clippy_lints/src/doc/mod.rs
index ca277e7eded..2b8f0bc503b 100644
--- a/clippy_lints/src/doc.rs
+++ b/clippy_lints/src/doc/mod.rs
@@ -1,21 +1,16 @@
 use clippy_utils::attrs::is_doc_hidden;
-use clippy_utils::diagnostics::{span_lint, span_lint_and_help, span_lint_and_note, span_lint_and_then};
+use clippy_utils::diagnostics::{span_lint, span_lint_and_help};
 use clippy_utils::macros::{is_panic, root_macro_call_first_node};
-use clippy_utils::source::snippet_with_applicability;
-use clippy_utils::ty::{implements_trait, is_type_diagnostic_item};
-use clippy_utils::{is_entrypoint_fn, method_chain_args, return_ty};
+use clippy_utils::ty::is_type_diagnostic_item;
+use clippy_utils::visitors::Visitable;
+use clippy_utils::{is_entrypoint_fn, method_chain_args};
 use pulldown_cmark::Event::{
     Code, End, FootnoteReference, HardBreak, Html, Rule, SoftBreak, Start, TaskListMarker, Text,
 };
 use pulldown_cmark::Tag::{CodeBlock, Heading, Item, Link, Paragraph};
 use pulldown_cmark::{BrokenLink, CodeBlockKind, CowStr, Options};
-use rustc_ast::ast::{Async, Attribute, Fn, FnRetTy, ItemKind};
-use rustc_ast::token::CommentKind;
-use rustc_ast::{AttrKind, AttrStyle};
+use rustc_ast::ast::Attribute;
 use rustc_data_structures::fx::FxHashSet;
-use rustc_data_structures::sync::Lrc;
-use rustc_errors::emitter::EmitterWriter;
-use rustc_errors::{Applicability, Handler, SuggestionStyle};
 use rustc_hir as hir;
 use rustc_hir::intravisit::{self, Visitor};
 use rustc_hir::{AnonConst, Expr};
@@ -23,20 +18,21 @@ use rustc_lint::{LateContext, LateLintPass};
 use rustc_middle::hir::nested_filter;
 use rustc_middle::lint::in_external_macro;
 use rustc_middle::ty;
-use rustc_parse::maybe_new_parser_from_source_str;
-use rustc_parse::parser::ForceCollect;
 use rustc_resolve::rustdoc::{
     add_doc_fragment, attrs_to_doc_fragments, main_body_opts, source_span_for_markdown_range, DocFragment,
 };
-use rustc_session::parse::ParseSess;
 use rustc_session::{declare_tool_lint, impl_lint_pass};
 use rustc_span::edition::Edition;
-use rustc_span::source_map::{FilePathMapping, SourceMap};
-use rustc_span::{sym, BytePos, FileName, Pos, Span};
+use rustc_span::{sym, Span};
 use std::ops::Range;
-use std::{io, thread};
 use url::Url;
 
+mod link_with_quotes;
+mod markdown;
+mod missing_headers;
+mod needless_doctest_main;
+mod suspicious_doc_comments;
+
 declare_clippy_lint! {
     /// ### What it does
     /// Checks for the presence of `_`, `::` or camel-case words
@@ -351,13 +347,9 @@ impl<'tcx> LateLintPass<'tcx> for DocMarkdown {
             hir::ItemKind::Fn(ref sig, _, body_id) => {
                 if !(is_entrypoint_fn(cx, item.owner_id.to_def_id()) || in_external_macro(cx.tcx.sess, item.span)) {
                     let body = cx.tcx.hir().body(body_id);
-                    let mut fpu = FindPanicUnwrap {
-                        cx,
-                        typeck_results: cx.tcx.typeck(item.owner_id.def_id),
-                        panic_span: None,
-                    };
-                    fpu.visit_expr(body.value);
-                    lint_for_missing_headers(cx, item.owner_id, sig, headers, Some(body_id), fpu.panic_span);
+
+                    let panic_span = FindPanicUnwrap::find_span(cx, cx.tcx.typeck(item.owner_id), body.value);
+                    missing_headers::check(cx, item.owner_id, sig, headers, Some(body_id), panic_span);
                 }
             },
             hir::ItemKind::Impl(impl_) => {
@@ -395,7 +387,7 @@ impl<'tcx> LateLintPass<'tcx> for DocMarkdown {
         };
         if let hir::TraitItemKind::Fn(ref sig, ..) = item.kind {
             if !in_external_macro(cx.tcx.sess, item.span) {
-                lint_for_missing_headers(cx, item.owner_id, sig, headers, None, None);
+                missing_headers::check(cx, item.owner_id, sig, headers, None, None);
             }
         }
     }
@@ -410,88 +402,9 @@ impl<'tcx> LateLintPass<'tcx> for DocMarkdown {
         }
         if let hir::ImplItemKind::Fn(ref sig, body_id) = item.kind {
             let body = cx.tcx.hir().body(body_id);
-            let mut fpu = FindPanicUnwrap {
-                cx,
-                typeck_results: cx.tcx.typeck(item.owner_id.def_id),
-                panic_span: None,
-            };
-            fpu.visit_expr(body.value);
-            lint_for_missing_headers(cx, item.owner_id, sig, headers, Some(body_id), fpu.panic_span);
-        }
-    }
-}
-
-fn lint_for_missing_headers(
-    cx: &LateContext<'_>,
-    owner_id: hir::OwnerId,
-    sig: &hir::FnSig<'_>,
-    headers: DocHeaders,
-    body_id: Option<hir::BodyId>,
-    panic_span: Option<Span>,
-) {
-    if !cx.effective_visibilities.is_exported(owner_id.def_id) {
-        return; // Private functions do not require doc comments
-    }
 
-    // do not lint if any parent has `#[doc(hidden)]` attribute (#7347)
-    if cx
-        .tcx
-        .hir()
-        .parent_iter(owner_id.into())
-        .any(|(id, _node)| is_doc_hidden(cx.tcx.hir().attrs(id)))
-    {
-        return;
-    }
-
-    let span = cx.tcx.def_span(owner_id);
-    match (headers.safety, sig.header.unsafety) {
-        (false, hir::Unsafety::Unsafe) => span_lint(
-            cx,
-            MISSING_SAFETY_DOC,
-            span,
-            "unsafe function's docs miss `# Safety` section",
-        ),
-        (true, hir::Unsafety::Normal) => span_lint(
-            cx,
-            UNNECESSARY_SAFETY_DOC,
-            span,
-            "safe function's docs have unnecessary `# Safety` section",
-        ),
-        _ => (),
-    }
-    if !headers.panics && panic_span.is_some() {
-        span_lint_and_note(
-            cx,
-            MISSING_PANICS_DOC,
-            span,
-            "docs for function which may panic missing `# Panics` section",
-            panic_span,
-            "first possible panic found here",
-        );
-    }
-    if !headers.errors {
-        if is_type_diagnostic_item(cx, return_ty(cx, owner_id), sym::Result) {
-            span_lint(
-                cx,
-                MISSING_ERRORS_DOC,
-                span,
-                "docs for function returning `Result` missing `# Errors` section",
-            );
-        } else if let Some(body_id) = body_id
-            && let Some(future) = cx.tcx.lang_items().future_trait()
-            && let typeck = cx.tcx.typeck_body(body_id)
-            && let body = cx.tcx.hir().body(body_id)
-            && let ret_ty = typeck.expr_ty(body.value)
-            && implements_trait(cx, ret_ty, future, &[])
-            && let ty::Coroutine(_, subs, _) = ret_ty.kind()
-            && is_type_diagnostic_item(cx, subs.as_coroutine().return_ty(), sym::Result)
-        {
-            span_lint(
-                cx,
-                MISSING_ERRORS_DOC,
-                span,
-                "docs for function returning `Result` missing `# Errors` section",
-            );
+            let panic_span = FindPanicUnwrap::find_span(cx, cx.tcx.typeck(item.owner_id), body.value);
+            missing_headers::check(cx, item.owner_id, sig, headers, Some(body_id), panic_span);
         }
     }
 }
@@ -515,6 +428,13 @@ struct DocHeaders {
     panics: bool,
 }
 
+/// Does some pre-processing on raw, desugared `#[doc]` attributes such as parsing them and
+/// then delegates to `check_doc`.
+/// Some lints are already checked here if they can work with attributes directly and don't need
+/// to work with markdown.
+/// Others are checked elsewhere, e.g. in `check_doc` if they need access to markdown, or
+/// back in the various late lint pass methods if they need the final doc headers, like "Safety" or
+/// "Panics" sections.
 fn check_attrs(cx: &LateContext<'_>, valid_idents: &FxHashSet<String>, attrs: &[Attribute]) -> Option<DocHeaders> {
     /// We don't want the parser to choke on intra doc links. Since we don't
     /// actually care about rendering them, just pretend that all broken links
@@ -528,7 +448,7 @@ fn check_attrs(cx: &LateContext<'_>, valid_idents: &FxHashSet<String>, attrs: &[
         return None;
     }
 
-    check_almost_inner_doc(cx, attrs);
+    suspicious_doc_comments::check(cx, attrs);
 
     let (fragments, _) = attrs_to_doc_fragments(attrs.iter().map(|attr| (attr, None)), true);
     let mut doc = String::new();
@@ -558,45 +478,12 @@ fn check_attrs(cx: &LateContext<'_>, valid_idents: &FxHashSet<String>, attrs: &[
     ))
 }
 
-/// Looks for `///!` and `/**!` comments, which were probably meant to be `//!` and `/*!`
-fn check_almost_inner_doc(cx: &LateContext<'_>, attrs: &[Attribute]) {
-    let replacements: Vec<_> = attrs
-        .iter()
-        .filter_map(|attr| {
-            if let AttrKind::DocComment(com_kind, sym) = attr.kind
-                && let AttrStyle::Outer = attr.style
-                && let Some(com) = sym.as_str().strip_prefix('!')
-            {
-                let sugg = match com_kind {
-                    CommentKind::Line => format!("//!{com}"),
-                    CommentKind::Block => format!("/*!{com}*/"),
-                };
-                Some((attr.span, sugg))
-            } else {
-                None
-            }
-        })
-        .collect();
-
-    if let Some((&(lo_span, _), &(hi_span, _))) = replacements.first().zip(replacements.last()) {
-        span_lint_and_then(
-            cx,
-            SUSPICIOUS_DOC_COMMENTS,
-            lo_span.to(hi_span),
-            "this is an outer doc comment and does not apply to the parent module or crate",
-            |diag| {
-                diag.multipart_suggestion(
-                    "use an inner doc comment to document the parent module or crate",
-                    replacements,
-                    Applicability::MaybeIncorrect,
-                );
-            },
-        );
-    }
-}
-
 const RUST_CODE: &[&str] = &["rust", "no_run", "should_panic", "compile_fail"];
 
+/// Checks parsed documentation.
+/// This walks the "events" (think sections of markdown) produced by `pulldown_cmark`,
+/// so lints here will generally access that information.
+/// Returns documentation headers -- whether a "Safety", "Errors", "Panic" section was found
 #[allow(clippy::too_many_lines)] // Only a big match statement
 fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize>)>>(
     cx: &LateContext<'_>,
@@ -665,7 +552,7 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
                 } else {
                     for (text, range) in text_to_check {
                         if let Some(span) = fragments.span(cx, range) {
-                            check_text(cx, valid_idents, &text, span);
+                            markdown::check(cx, valid_idents, &text, span);
                         }
                     }
                 }
@@ -692,11 +579,11 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
                 if in_code {
                     if is_rust && !no_test {
                         let edition = edition.unwrap_or_else(|| cx.tcx.sess.edition());
-                        check_code(cx, &text, edition, range.clone(), fragments);
+                        needless_doctest_main::check(cx, &text, edition, range.clone(), fragments);
                     }
                 } else {
                     if in_link.is_some() {
-                        check_link_quotes(cx, trimmed_text, range.clone(), fragments);
+                        link_with_quotes::check(cx, trimmed_text, range.clone(), fragments);
                     }
                     if let Some(link) = in_link.as_ref()
                         && let Ok(url) = Url::parse(link)
@@ -713,208 +600,28 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
     headers
 }
 
-fn check_link_quotes(cx: &LateContext<'_>, trimmed_text: &str, range: Range<usize>, fragments: Fragments<'_>) {
-    if trimmed_text.starts_with('\'')
-        && trimmed_text.ends_with('\'')
-        && let Some(span) = fragments.span(cx, range)
-    {
-        span_lint(
-            cx,
-            DOC_LINK_WITH_QUOTES,
-            span,
-            "possible intra-doc link using quotes instead of backticks",
-        );
-    }
-}
-
-fn check_code(cx: &LateContext<'_>, text: &str, edition: Edition, range: Range<usize>, fragments: Fragments<'_>) {
-    fn has_needless_main(code: String, edition: Edition) -> bool {
-        rustc_driver::catch_fatal_errors(|| {
-            rustc_span::create_session_globals_then(edition, || {
-                let filename = FileName::anon_source_code(&code);
-
-                let fallback_bundle =
-                    rustc_errors::fallback_fluent_bundle(rustc_driver::DEFAULT_LOCALE_RESOURCES.to_vec(), false);
-                let emitter = EmitterWriter::new(Box::new(io::sink()), fallback_bundle);
-                let handler = Handler::with_emitter(Box::new(emitter)).disable_warnings();
-                #[expect(clippy::arc_with_non_send_sync)] // `Lrc` is expected by with_span_handler
-                let sm = Lrc::new(SourceMap::new(FilePathMapping::empty()));
-                let sess = ParseSess::with_span_handler(handler, sm);
-
-                let mut parser = match maybe_new_parser_from_source_str(&sess, filename, code) {
-                    Ok(p) => p,
-                    Err(errs) => {
-                        drop(errs);
-                        return false;
-                    },
-                };
-
-                let mut relevant_main_found = false;
-                loop {
-                    match parser.parse_item(ForceCollect::No) {
-                        Ok(Some(item)) => match &item.kind {
-                            ItemKind::Fn(box Fn {
-                                sig, body: Some(block), ..
-                            }) if item.ident.name == sym::main => {
-                                let is_async = matches!(sig.header.asyncness, Async::Yes { .. });
-                                let returns_nothing = match &sig.decl.output {
-                                    FnRetTy::Default(..) => true,
-                                    FnRetTy::Ty(ty) if ty.kind.is_unit() => true,
-                                    FnRetTy::Ty(_) => false,
-                                };
-
-                                if returns_nothing && !is_async && !block.stmts.is_empty() {
-                                    // This main function should be linted, but only if there are no other functions
-                                    relevant_main_found = true;
-                                } else {
-                                    // This main function should not be linted, we're done
-                                    return false;
-                                }
-                            },
-                            // Tests with one of these items are ignored
-                            ItemKind::Static(..)
-                            | ItemKind::Const(..)
-                            | ItemKind::ExternCrate(..)
-                            | ItemKind::ForeignMod(..)
-                            // Another function was found; this case is ignored
-                            | ItemKind::Fn(..) => return false,
-                            _ => {},
-                        },
-                        Ok(None) => break,
-                        Err(e) => {
-                            e.cancel();
-                            return false;
-                        },
-                    }
-                }
-
-                relevant_main_found
-            })
-        })
-        .ok()
-        .unwrap_or_default()
-    }
-
-    let trailing_whitespace = text.len() - text.trim_end().len();
-
-    // Because of the global session, we need to create a new session in a different thread with
-    // the edition we need.
-    let text = text.to_owned();
-    if thread::spawn(move || has_needless_main(text, edition))
-        .join()
-        .expect("thread::spawn failed")
-        && let Some(span) = fragments.span(cx, range.start..range.end - trailing_whitespace)
-    {
-        span_lint(cx, NEEDLESS_DOCTEST_MAIN, span, "needless `fn main` in doctest");
-    }
-}
-
-fn check_text(cx: &LateContext<'_>, valid_idents: &FxHashSet<String>, text: &str, span: Span) {
-    for word in text.split(|c: char| c.is_whitespace() || c == '\'') {
-        // Trim punctuation as in `some comment (see foo::bar).`
-        //                                                   ^^
-        // Or even as in `_foo bar_` which is emphasized. Also preserve `::` as a prefix/suffix.
-        let mut word = word.trim_matches(|c: char| !c.is_alphanumeric() && c != ':');
-
-        // Remove leading or trailing single `:` which may be part of a sentence.
-        if word.starts_with(':') && !word.starts_with("::") {
-            word = word.trim_start_matches(':');
-        }
-        if word.ends_with(':') && !word.ends_with("::") {
-            word = word.trim_end_matches(':');
-        }
-
-        if valid_idents.contains(word) || word.chars().all(|c| c == ':') {
-            continue;
-        }
-
-        // Adjust for the current word
-        let offset = word.as_ptr() as usize - text.as_ptr() as usize;
-        let span = Span::new(
-            span.lo() + BytePos::from_usize(offset),
-            span.lo() + BytePos::from_usize(offset + word.len()),
-            span.ctxt(),
-            span.parent(),
-        );
-
-        check_word(cx, word, span);
-    }
-}
-
-fn check_word(cx: &LateContext<'_>, word: &str, span: Span) {
-    /// Checks if a string is upper-camel-case, i.e., starts with an uppercase and
-    /// contains at least two uppercase letters (`Clippy` is ok) and one lower-case
-    /// letter (`NASA` is ok).
-    /// Plurals are also excluded (`IDs` is ok).
-    fn is_camel_case(s: &str) -> bool {
-        if s.starts_with(|c: char| c.is_ascii_digit() | c.is_ascii_lowercase()) {
-            return false;
-        }
-
-        let s = s.strip_suffix('s').unwrap_or(s);
-
-        s.chars().all(char::is_alphanumeric)
-            && s.chars().filter(|&c| c.is_uppercase()).take(2).count() > 1
-            && s.chars().filter(|&c| c.is_lowercase()).take(1).count() > 0
-    }
-
-    fn has_underscore(s: &str) -> bool {
-        s != "_" && !s.contains("\\_") && s.contains('_')
-    }
-
-    fn has_hyphen(s: &str) -> bool {
-        s != "-" && s.contains('-')
-    }
-
-    if let Ok(url) = Url::parse(word) {
-        // try to get around the fact that `foo::bar` parses as a valid URL
-        if !url.cannot_be_a_base() {
-            span_lint(
-                cx,
-                DOC_MARKDOWN,
-                span,
-                "you should put bare URLs between `<`/`>` or make a proper Markdown link",
-            );
-
-            return;
-        }
-    }
-
-    // We assume that mixed-case words are not meant to be put inside backticks. (Issue #2343)
-    if has_underscore(word) && has_hyphen(word) {
-        return;
-    }
-
-    if has_underscore(word) || word.contains("::") || is_camel_case(word) {
-        let mut applicability = Applicability::MachineApplicable;
-
-        span_lint_and_then(
-            cx,
-            DOC_MARKDOWN,
-            span,
-            "item in documentation is missing backticks",
-            |diag| {
-                let snippet = snippet_with_applicability(cx, span, "..", &mut applicability);
-                diag.span_suggestion_with_style(
-                    span,
-                    "try",
-                    format!("`{snippet}`"),
-                    applicability,
-                    // always show the suggestion in a separate line, since the
-                    // inline presentation adds another pair of backticks
-                    SuggestionStyle::ShowAlways,
-                );
-            },
-        );
-    }
-}
-
 struct FindPanicUnwrap<'a, 'tcx> {
     cx: &'a LateContext<'tcx>,
     panic_span: Option<Span>,
     typeck_results: &'tcx ty::TypeckResults<'tcx>,
 }
 
+impl<'a, 'tcx> FindPanicUnwrap<'a, 'tcx> {
+    pub fn find_span(
+        cx: &'a LateContext<'tcx>,
+        typeck_results: &'tcx ty::TypeckResults<'tcx>,
+        body: impl Visitable<'tcx>,
+    ) -> Option<Span> {
+        let mut vis = Self {
+            cx,
+            panic_span: None,
+            typeck_results,
+        };
+        body.visit(&mut vis);
+        vis.panic_span
+    }
+}
+
 impl<'a, 'tcx> Visitor<'tcx> for FindPanicUnwrap<'a, 'tcx> {
     type NestedFilter = nested_filter::OnlyBodies;
 
diff --git a/clippy_lints/src/doc/needless_doctest_main.rs b/clippy_lints/src/doc/needless_doctest_main.rs
new file mode 100644
index 00000000000..4d7d7611dd8
--- /dev/null
+++ b/clippy_lints/src/doc/needless_doctest_main.rs
@@ -0,0 +1,100 @@
+use std::ops::Range;
+use std::{io, thread};
+
+use crate::doc::NEEDLESS_DOCTEST_MAIN;
+use clippy_utils::diagnostics::span_lint;
+use rustc_ast::{Async, Fn, FnRetTy, ItemKind};
+use rustc_data_structures::sync::Lrc;
+use rustc_errors::emitter::EmitterWriter;
+use rustc_errors::Handler;
+use rustc_lint::LateContext;
+use rustc_parse::maybe_new_parser_from_source_str;
+use rustc_parse::parser::ForceCollect;
+use rustc_session::parse::ParseSess;
+use rustc_span::edition::Edition;
+use rustc_span::source_map::{FilePathMapping, SourceMap};
+use rustc_span::{sym, FileName};
+
+use super::Fragments;
+
+pub fn check(cx: &LateContext<'_>, text: &str, edition: Edition, range: Range<usize>, fragments: Fragments<'_>) {
+    fn has_needless_main(code: String, edition: Edition) -> bool {
+        rustc_driver::catch_fatal_errors(|| {
+            rustc_span::create_session_globals_then(edition, || {
+                let filename = FileName::anon_source_code(&code);
+
+                let fallback_bundle =
+                    rustc_errors::fallback_fluent_bundle(rustc_driver::DEFAULT_LOCALE_RESOURCES.to_vec(), false);
+                let emitter = EmitterWriter::new(Box::new(io::sink()), fallback_bundle);
+                let handler = Handler::with_emitter(Box::new(emitter)).disable_warnings();
+                #[expect(clippy::arc_with_non_send_sync)] // `Lrc` is expected by with_span_handler
+                let sm = Lrc::new(SourceMap::new(FilePathMapping::empty()));
+                let sess = ParseSess::with_span_handler(handler, sm);
+
+                let mut parser = match maybe_new_parser_from_source_str(&sess, filename, code) {
+                    Ok(p) => p,
+                    Err(errs) => {
+                        drop(errs);
+                        return false;
+                    },
+                };
+
+                let mut relevant_main_found = false;
+                loop {
+                    match parser.parse_item(ForceCollect::No) {
+                        Ok(Some(item)) => match &item.kind {
+                            ItemKind::Fn(box Fn {
+                                sig, body: Some(block), ..
+                            }) if item.ident.name == sym::main => {
+                                let is_async = matches!(sig.header.asyncness, Async::Yes { .. });
+                                let returns_nothing = match &sig.decl.output {
+                                    FnRetTy::Default(..) => true,
+                                    FnRetTy::Ty(ty) if ty.kind.is_unit() => true,
+                                    FnRetTy::Ty(_) => false,
+                                };
+
+                                if returns_nothing && !is_async && !block.stmts.is_empty() {
+                                    // This main function should be linted, but only if there are no other functions
+                                    relevant_main_found = true;
+                                } else {
+                                    // This main function should not be linted, we're done
+                                    return false;
+                                }
+                            },
+                            // Tests with one of these items are ignored
+                            ItemKind::Static(..)
+                            | ItemKind::Const(..)
+                            | ItemKind::ExternCrate(..)
+                            | ItemKind::ForeignMod(..)
+                            // Another function was found; this case is ignored
+                            | ItemKind::Fn(..) => return false,
+                            _ => {},
+                        },
+                        Ok(None) => break,
+                        Err(e) => {
+                            e.cancel();
+                            return false;
+                        },
+                    }
+                }
+
+                relevant_main_found
+            })
+        })
+        .ok()
+        .unwrap_or_default()
+    }
+
+    let trailing_whitespace = text.len() - text.trim_end().len();
+
+    // Because of the global session, we need to create a new session in a different thread with
+    // the edition we need.
+    let text = text.to_owned();
+    if thread::spawn(move || has_needless_main(text, edition))
+        .join()
+        .expect("thread::spawn failed")
+        && let Some(span) = fragments.span(cx, range.start..range.end - trailing_whitespace)
+    {
+        span_lint(cx, NEEDLESS_DOCTEST_MAIN, span, "needless `fn main` in doctest");
+    }
+}
diff --git a/clippy_lints/src/doc/suspicious_doc_comments.rs b/clippy_lints/src/doc/suspicious_doc_comments.rs
new file mode 100644
index 00000000000..d7ad30efec3
--- /dev/null
+++ b/clippy_lints/src/doc/suspicious_doc_comments.rs
@@ -0,0 +1,48 @@
+use clippy_utils::diagnostics::span_lint_and_then;
+use rustc_ast::token::CommentKind;
+use rustc_ast::{AttrKind, AttrStyle, Attribute};
+use rustc_errors::Applicability;
+use rustc_lint::LateContext;
+use rustc_span::Span;
+
+use super::SUSPICIOUS_DOC_COMMENTS;
+
+pub fn check(cx: &LateContext<'_>, attrs: &[Attribute]) {
+    let replacements: Vec<_> = collect_doc_replacements(attrs);
+
+    if let Some((&(lo_span, _), &(hi_span, _))) = replacements.first().zip(replacements.last()) {
+        span_lint_and_then(
+            cx,
+            SUSPICIOUS_DOC_COMMENTS,
+            lo_span.to(hi_span),
+            "this is an outer doc comment and does not apply to the parent module or crate",
+            |diag| {
+                diag.multipart_suggestion(
+                    "use an inner doc comment to document the parent module or crate",
+                    replacements,
+                    Applicability::MaybeIncorrect,
+                );
+            },
+        );
+    }
+}
+
+fn collect_doc_replacements(attrs: &[Attribute]) -> Vec<(Span, String)> {
+    attrs
+        .iter()
+        .filter_map(|attr| {
+            if let AttrKind::DocComment(com_kind, sym) = attr.kind
+                && let AttrStyle::Outer = attr.style
+                && let Some(com) = sym.as_str().strip_prefix('!')
+            {
+                let sugg = match com_kind {
+                    CommentKind::Line => format!("//!{com}"),
+                    CommentKind::Block => format!("/*!{com}*/"),
+                };
+                Some((attr.span, sugg))
+            } else {
+                None
+            }
+        })
+        .collect()
+}
diff --git a/tests/ui/doc_link_with_quotes.rs b/tests/ui/doc_link_with_quotes.rs
index 37d0d135957..48e1b1819c6 100644
--- a/tests/ui/doc_link_with_quotes.rs
+++ b/tests/ui/doc_link_with_quotes.rs
@@ -11,6 +11,12 @@ pub fn foo() {
     bar()
 }
 
+/// Calls ["bar"] uselessly
+//~^ ERROR: possible intra-doc link using quotes instead of backticks
+pub fn foo2() {
+    bar()
+}
+
 /// # Examples
 /// This demonstrates issue \#8961
 /// ```
diff --git a/tests/ui/doc_link_with_quotes.stderr b/tests/ui/doc_link_with_quotes.stderr
index 2db1bc09289..cd4f87c56b4 100644
--- a/tests/ui/doc_link_with_quotes.stderr
+++ b/tests/ui/doc_link_with_quotes.stderr
@@ -7,5 +7,11 @@ LL | /// Calls ['bar'] uselessly
    = note: `-D clippy::doc-link-with-quotes` implied by `-D warnings`
    = help: to override `-D warnings` add `#[allow(clippy::doc_link_with_quotes)]`
 
-error: aborting due to previous error
+error: possible intra-doc link using quotes instead of backticks
+  --> $DIR/doc_link_with_quotes.rs:14:12
+   |
+LL | /// Calls ["bar"] uselessly
+   |            ^^^^^
+
+error: aborting due to 2 previous errors