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 @@ -7075,6 +7075,7 @@ Released 2018-09-13
[`diverging_sub_expression`]: https://rust-lang.github.io/rust-clippy/main/index.html#diverging_sub_expression
[`doc_broken_link`]: https://rust-lang.github.io/rust-clippy/main/index.html#doc_broken_link
[`doc_comment_double_space_linebreaks`]: https://rust-lang.github.io/rust-clippy/main/index.html#doc_comment_double_space_linebreaks
[`doc_examples_missing_item`]: https://rust-lang.github.io/rust-clippy/main/index.html#doc_examples_missing_item
[`doc_include_without_cfg`]: https://rust-lang.github.io/rust-clippy/main/index.html#doc_include_without_cfg
[`doc_lazy_continuation`]: https://rust-lang.github.io/rust-clippy/main/index.html#doc_lazy_continuation
[`doc_link_code`]: https://rust-lang.github.io/rust-clippy/main/index.html#doc_link_code
Expand Down
1 change: 1 addition & 0 deletions book/src/lint_configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ Whether to also run the listed lints on private items.

---
**Affected lints:**
* [`doc_examples_missing_item`](https://rust-lang.github.io/rust-clippy/main/index.html#doc_examples_missing_item)
* [`missing_errors_doc`](https://rust-lang.github.io/rust-clippy/main/index.html#missing_errors_doc)
* [`missing_panics_doc`](https://rust-lang.github.io/rust-clippy/main/index.html#missing_panics_doc)
* [`missing_safety_doc`](https://rust-lang.github.io/rust-clippy/main/index.html#missing_safety_doc)
Expand Down
8 changes: 7 additions & 1 deletion clippy_config/src/conf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,13 @@ define_Conf! {
#[lints(inconsistent_struct_constructor)]
check_inconsistent_struct_field_initializers("check-inconsistent-struct-field-initializers"): bool = false,
/// Whether to also run the listed lints on private items.
#[lints(missing_errors_doc, missing_panics_doc, missing_safety_doc, unnecessary_safety_doc)]
#[lints(
doc_examples_missing_item,
missing_errors_doc,
missing_panics_doc,
missing_safety_doc,
unnecessary_safety_doc,
)]
check_private_items("check-private-items"): bool = false,
/// The maximum cognitive complexity a function can have
#[lints(cognitive_complexity)]
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 @@ -118,6 +118,7 @@ pub static LINTS: &[&::declare_clippy_lint::LintInfo] = &[
crate::disallowed_types::DISALLOWED_TYPES_INFO,
crate::doc::DOC_BROKEN_LINK_INFO,
crate::doc::DOC_COMMENT_DOUBLE_SPACE_LINEBREAKS_INFO,
crate::doc::DOC_EXAMPLES_MISSING_ITEM_INFO,
crate::doc::DOC_INCLUDE_WITHOUT_CFG_INFO,
crate::doc::DOC_LAZY_CONTINUATION_INFO,
crate::doc::DOC_LINK_CODE_INFO,
Expand Down
50 changes: 50 additions & 0 deletions clippy_lints/src/doc/doc_examples_missing_item.rs

@notriddle notriddle Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why is this so big?! needless_doctest_main.rs is less than a hundred lines of code and it’s doing a similar check to this one.

View changes since the review

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I familiarized with the similar linting, refactored and trimmed down significantly. Thanks for the patience.

Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
use super::{DOC_EXAMPLES_MISSING_ITEM, is_public_api};
use clippy_utils::attrs::is_proc_macro;
use clippy_utils::diagnostics::span_lint_and_help;
use clippy_utils::{is_lint_allowed, is_trait_impl_item, tokenize_with_text};
use rustc_hir::def::DefKind;
use rustc_lexer::TokenKind;
use rustc_lint::LateContext;
use rustc_span::Ident;

pub(super) fn item_ident(cx: &LateContext<'_>, check_private_items: bool) -> Option<Ident> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

instead of this function being if..return None if..return None, how about we make this into an bigger if-let, since if you specify the case where you want the ident it is simpler to reason about for readers due to not having to deal with double negations, ...

I in particularly stubled over if !check_private_items

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ok, I expect the positive case if chain might get long but I saw elsewhere in clippy lints occasionally there are fairly large ones. Will rewrite shortly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Cleaned up, actually found a small duplication with another lint and cleaned both. Lmk if that is okay as part of this PR or whether it would be ideal to split it.

let hir_id = cx.last_node_with_lint_attrs;
let owner_id = hir_id.as_owner()?;
if !is_lint_allowed(cx, DOC_EXAMPLES_MISSING_ITEM, hir_id)
// Types and traits are skipped because examples often use them through a glob import without naming
// them, and telling which glob-imported names an example uses needs name resolution on every doctest.
&& matches!(
cx.tcx.def_kind(owner_id),
DefKind::Fn | DefKind::AssocFn | DefKind::Const | DefKind::AssocConst | DefKind::Static { .. }
)
&& !is_trait_impl_item(cx, hir_id)
&& !is_proc_macro(cx.tcx.hir_attrs(hir_id))
&& (check_private_items || is_public_api(cx, owner_id))
{
cx.tcx.opt_item_ident(owner_id.to_def_id())
} else {
None
}
}

pub(super) fn mentions(code: &str, ident: Ident) -> bool {
if !code.contains(ident.as_str()) {
return false;
}

tokenize_with_text(code).any(|(kind, text, _)| {
matches!(kind, TokenKind::Ident | TokenKind::RawIdent)
&& text.strip_prefix("r#").unwrap_or(text) == ident.as_str()
})
}
Comment on lines +30 to +39

@Gri-ffin Gri-ffin Sep 21, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looking at the lintcheck results, we might want to skip structs and traits as they would be trickier to handle, they all seem like false positives.

View changes since the review

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I agree that this check on methods/functions has likely much lower false positives, but a few (6 our of 13) of the cases of interest in Diesel which I caught with dejadoc were on structs, therefore I would believe it still has value.

@notriddle is there any particular acceptable pattern in clippy to say "check X, but only for item kinds in set Y", so that users may decide whether they want to enable this on any particular set of items? Or should it explode into a set of lints like doc_examples_missing_*?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I wasn't aware it was possible to configure each lint, I hadn't ever had to config it in a repo, thank you for pointing it out. I will familiarize with it and proceed as suggested.

@LucaCappelletti94 LucaCappelletti94 Sep 22, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added the configuration, by default parametrized as @Gri-ffin suggests, and optionally parametrizable to include all item kinds. @notriddle please do let me whether I have set it up correctly or I have missed using some of the intended patterns/methods.

@notriddle notriddle Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don’t feel qualified to answer this question. I know Clippy has “restriction” lints that are allowed to flag harmless code, as long as the lint perform as advertised, but I’m not sure how open the team is to adding them.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I removed the configuration together with the struct, enum, union and trait support, so the lint now only covers functions, methods, constants and statics. Should be good to go now.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@Gri-ffin do the more recent updates address the earlier issues you raised satisfactorily?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

They are yes, due to policy though you should find a reviewer in #t-clippy or #llm-reviews to review this PR given it was assisted by LLM. I was just doing a drive by review.

Here is the link to the policy: https://forge.rust-lang.org/policies/llm-usage.html

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I appreciate it, just wanted to make sure the lint is now as you would expect it to be.


pub(super) fn report(cx: &LateContext<'_>, ident: Ident) {
span_lint_and_help(
cx,
DOC_EXAMPLES_MISSING_ITEM,
cx.tcx.def_span(cx.last_node_with_lint_attrs.owner),
format!("none of the documentation examples mention `{ident}`"),
None,
"consider adding an example that uses this item",
);
}
18 changes: 5 additions & 13 deletions clippy_lints/src/doc/missing_headers.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
use super::{DocHeaders, MISSING_ERRORS_DOC, MISSING_PANICS_DOC, MISSING_SAFETY_DOC, UNNECESSARY_SAFETY_DOC};
use super::{
DocHeaders, MISSING_ERRORS_DOC, MISSING_PANICS_DOC, MISSING_SAFETY_DOC, UNNECESSARY_SAFETY_DOC, is_public_api,
};
use clippy_utils::diagnostics::{span_lint, span_lint_and_note};
use clippy_utils::macros::{is_panic, root_macro_call_first_node};
use clippy_utils::res::MaybeDef as _;
use clippy_utils::ty::implements_trait_with_env;
use clippy_utils::visitors::for_each_expr;
use clippy_utils::{fulfill_or_allowed, is_doc_hidden, is_inside_always_const_context, method_chain_args, return_ty};
use clippy_utils::{fulfill_or_allowed, is_inside_always_const_context, method_chain_args, return_ty};
use rustc_hir::{BodyId, FnSig, OwnerId, Safety};
use rustc_lint::LateContext;
use rustc_middle::ty;
Expand All @@ -19,17 +21,7 @@ pub fn check(
body_id: Option<BodyId>,
check_private_items: bool,
) {
if !check_private_items && !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 !check_private_items
&& cx
.tcx
.hir_parent_iter(owner_id.into())
.any(|(id, _node)| is_doc_hidden(cx.tcx.hir_attrs(id)))
{
if !check_private_items && !is_public_api(cx, owner_id) {
return;
}

Expand Down
75 changes: 71 additions & 4 deletions clippy_lints/src/doc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use rustc_ast::token::DocFragmentKind;
use rustc_attr_ir::Attribute;
use rustc_data_structures::fx::FxHashSet;
use rustc_errors::Applicability;
use rustc_hir::{FieldDef, ImplItemKind, ItemKind, Node, Safety, TraitItemKind};
use rustc_hir::{FieldDef, ImplItemKind, ItemKind, Node, OwnerId, Safety, TraitItemKind};
use rustc_lint::{EarlyContext, EarlyLintPass, LateContext, LateLintPass, LintContext as _, impl_lint_pass};
use rustc_resolve::rustdoc::pulldown_cmark::Event::{
Code, DisplayMath, End, FootnoteReference, HardBreak, Html, InlineHtml, InlineMath, Rule, SoftBreak, Start,
Expand All @@ -21,12 +21,13 @@ use rustc_resolve::rustdoc::{
DocFragment, add_doc_fragment, attrs_to_doc_fragments, main_body_opts, pulldown_cmark,
source_span_for_markdown_range, span_of_fragments,
};
use rustc_span::Span;
use rustc_span::{Ident, Span};
use std::ops::Range;
use url::Url;

mod broken_link;
mod doc_comment_double_space_linebreaks;
mod doc_examples_missing_item;
mod doc_paragraphs_missing_punctuation;
mod doc_suspicious_footnotes;
mod include_in_doc_without_cfg;
Expand Down Expand Up @@ -100,6 +101,39 @@ declare_clippy_lint! {
"double space used for doc comment hard line break instead of `\\`"
}

declare_clippy_lint! {
/// ### What it does
/// Checks for functions, constants and statics whose documentation examples never mention
/// the item. A block tagged `ignore` or `compile_fail` is not an example for this purpose.
///
/// ### Why is this bad?
/// A copied example keeps passing as a doctest while demonstrating another item, leaving
/// the documented item with no example.
///
/// ### Known problems
/// The check is lexical, so an unrelated identifier spelled like the item satisfies it.
///
/// ### Example
/// ```no_run
/// /// ```
/// /// assert_eq!(triple(2), 6);
/// /// ```
/// pub fn double(x: u32) -> u32 { x * 2 }
/// # pub fn triple(x: u32) -> u32 { x * 3 }
/// ```
/// Use instead:
/// ```no_run
/// /// ```
/// /// assert_eq!(double(2), 4);
/// /// ```
/// pub fn double(x: u32) -> u32 { x * 2 }
/// ```
#[clippy::version = "1.101.0"]
pub DOC_EXAMPLES_MISSING_ITEM,
pedantic,
"documentation examples never mention the documented item"
}

declare_clippy_lint! {
/// ### What it does
/// Checks if included files in doc comments are included only for `cfg(doc)`.
Expand Down Expand Up @@ -709,6 +743,7 @@ declare_clippy_lint! {
impl_lint_pass!(Documentation => [
DOC_BROKEN_LINK,
DOC_COMMENT_DOUBLE_SPACE_LINEBREAKS,
DOC_EXAMPLES_MISSING_ITEM,
DOC_INCLUDE_WITHOUT_CFG,
DOC_LAZY_CONTINUATION,
DOC_LINK_CODE,
Expand Down Expand Up @@ -751,7 +786,7 @@ impl EarlyLintPass for Documentation {

impl<'tcx> LateLintPass<'tcx> for Documentation {
fn check_attributes(&mut self, cx: &LateContext<'tcx>, attrs: &'tcx [Attribute]) {
let Some(headers) = check_attrs(cx, self.valid_idents, attrs) else {
let Some(headers) = check_attrs(cx, self.valid_idents, self.check_private_items, attrs) else {
return;
};

Expand Down Expand Up @@ -858,14 +893,28 @@ struct DocHeaders {
first_paragraph_text_len: usize,
}

/// Whether the item is part of the documented public API, meaning exported and not under `#[doc(hidden)]` (#7347).
fn is_public_api(cx: &LateContext<'_>, owner_id: OwnerId) -> bool {
cx.effective_visibilities.is_exported(owner_id.def_id)
&& !cx
.tcx
.hir_parent_iter(owner_id.into())
.any(|(id, _)| is_doc_hidden(cx.tcx.hir_attrs(id)))
}

/// 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> {
fn check_attrs(
cx: &LateContext<'_>,
valid_idents: &FxHashSet<String>,
check_private_items: bool,
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
// point to a fake address.
Expand All @@ -878,6 +927,7 @@ fn check_attrs(cx: &LateContext<'_>, valid_idents: &FxHashSet<String>, attrs: &[
return None;
}

let item_ident = doc_examples_missing_item::item_ident(cx, check_private_items);
let (fragments, _) = attrs_to_doc_fragments(
attrs.iter().filter_map(|attr| {
let (_, kind) = attr.doc_str_and_fragment_kind()?;
Expand Down Expand Up @@ -966,6 +1016,7 @@ fn check_attrs(cx: &LateContext<'_>, valid_idents: &FxHashSet<String>, attrs: &[
fragments: &fragments,
},
attrs,
item_ident,
))
}

Expand Down Expand Up @@ -1119,6 +1170,7 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
doc: &str,
fragments: Fragments<'_>,
attrs: &[Attribute],
item_ident: Option<Ident>,
) -> DocHeaders {
// true if a safety header was found
let mut headers = DocHeaders::default();
Expand All @@ -1133,6 +1185,8 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
let mut blockquote_level = 0;
let mut collected_breaks: Vec<Span> = Vec::new();
let mut is_first_paragraph = true;
let mut has_example = false;
let mut example_mentions_item = false;

let mut containers = Vec::new();

Expand Down Expand Up @@ -1352,6 +1406,12 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
if !tags.no_run && !tags.test_harness {
test_attr_in_doctest::check(cx, &text, range.start, fragments);
}

if let Some(ident) = item_ident {
has_example = true;
example_mentions_item =
example_mentions_item || doc_examples_missing_item::mentions(&text, ident);
}
}
} else {
if in_link.is_some() {
Expand All @@ -1374,6 +1434,13 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
}
}

if let Some(ident) = item_ident
&& has_example
&& !example_mentions_item
{
doc_examples_missing_item::report(cx, ident);
}

doc_comment_double_space_linebreaks::check(cx, &collected_breaks);

headers
Expand Down
5 changes: 5 additions & 0 deletions clippy_utils/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ macro_rules! extract_msrv_attr {
/// dbg!(def);
/// // ^^^ input
/// ```
#[expect(clippy::doc_examples_missing_item, reason = "the example is the analyzed code")]
pub fn expr_or_init<'a, 'b, 'tcx: 'b>(cx: &LateContext<'tcx>, mut expr: &'a Expr<'b>) -> &'a Expr<'b> {
while let Some(init) = expr
.res_local_id()
Expand Down Expand Up @@ -454,6 +455,7 @@ pub fn path_to_local_with_projections(expr: &Expr<'_>) -> Option<HirId> {
/// }
/// }
/// ```
#[expect(clippy::doc_examples_missing_item, reason = "the example is the analyzed code")]
pub fn trait_ref_of_method<'tcx>(cx: &LateContext<'tcx>, owner: OwnerId) -> Option<&'tcx TraitRef<'tcx>> {
if let Node::Item(item) = cx.tcx.hir_node(cx.tcx.hir_owner_parent(owner))
&& let ItemKind::Impl(impl_) = &item.kind
Expand Down Expand Up @@ -749,6 +751,7 @@ fn is_default_equivalent_from(cx: &LateContext<'_>, from_func: &Expr<'_>, arg: &
///
/// Note that this check is not recursive, so passing the `if` expression will always return true
/// even though sub-expressions might return false.
#[expect(clippy::doc_examples_missing_item, reason = "the example is the analyzed code")]
pub fn can_move_expr_to_closure_no_visit<'tcx>(
cx: &LateContext<'tcx>,
expr: &'tcx Expr<'_>,
Expand Down Expand Up @@ -1423,6 +1426,7 @@ pub fn is_expn_of(mut span: Span, name: Symbol) -> Option<Span> {
/// ```
/// `42` is considered expanded from `foo!` and `bar!` by `is_expn_of` but only
/// from `bar!` by `is_direct_expn_of`.
#[expect(clippy::doc_examples_missing_item, reason = "the example is the analyzed code")]
#[must_use]
pub fn is_direct_expn_of(span: Span, name: Symbol) -> Option<Span> {
if span.from_expansion() {
Expand Down Expand Up @@ -2086,6 +2090,7 @@ pub fn is_no_core_crate(cx: &LateContext<'_>) -> bool {
/// fn f() {}
/// }
/// ```
#[expect(clippy::doc_examples_missing_item, reason = "the example is the analyzed code")]
pub fn is_trait_impl_item(cx: &LateContext<'_>, hir_id: HirId) -> bool {
if let Node::Item(item) = cx.tcx.parent_hir_node(hir_id) {
matches!(item.kind, ItemKind::Impl(Impl { of_trait: Some(_), .. }))
Expand Down
23 changes: 23 additions & 0 deletions tests/ui-toml/private-doc-errors/doc_lints.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#![deny(
clippy::doc_examples_missing_item,
clippy::missing_errors_doc,
clippy::missing_panics_doc,
clippy::unnecessary_safety_doc
Expand Down Expand Up @@ -51,6 +52,28 @@ pub mod __macro {
}
}

mod examples {
/// ```
/// let _ = 1;
/// ```
fn private_copied() {}
//~^ doc_examples_missing_item

/// ```
/// private_own();
/// ```
fn private_own() {}
}

#[doc(hidden)]
pub mod hidden_examples {
/// ```
/// let _ = 1;
/// ```
pub fn under_hidden() {}
//~^ doc_examples_missing_item
}

#[warn(clippy::missing_errors_doc)]
#[test]
fn test() -> Result<(), ()> {
Expand Down
Loading
Loading