Skip to content

doc_suspicious_footnotes: lint text that looks like a footnote #14708

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 5, 2025
Merged
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 @@ -5736,6 +5736,7 @@ Released 2018-09-13
[`doc_markdown`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_markdown
[`doc_nested_refdefs`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_nested_refdefs
[`doc_overindented_list_items`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_overindented_list_items
[`doc_suspicious_footnotes`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_suspicious_footnotes
[`double_comparisons`]: https://rust-lang.github.io/rust-clippy/master/index.html#double_comparisons
[`double_ended_iterator_last`]: https://rust-lang.github.io/rust-clippy/master/index.html#double_ended_iterator_last
[`double_must_use`]: https://rust-lang.github.io/rust-clippy/master/index.html#double_must_use
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 @@ -119,6 +119,7 @@ pub static LINTS: &[&crate::LintInfo] = &[
crate::doc::DOC_MARKDOWN_INFO,
crate::doc::DOC_NESTED_REFDEFS_INFO,
crate::doc::DOC_OVERINDENTED_LIST_ITEMS_INFO,
crate::doc::DOC_SUSPICIOUS_FOOTNOTES_INFO,
crate::doc::EMPTY_DOCS_INFO,
crate::doc::MISSING_ERRORS_DOC_INFO,
crate::doc::MISSING_PANICS_DOC_INFO,
Expand Down
113 changes: 113 additions & 0 deletions clippy_lints/src/doc/doc_suspicious_footnotes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
use clippy_utils::diagnostics::span_lint_and_then;
use rustc_ast::token::CommentKind;
use rustc_errors::Applicability;
use rustc_hir::{AttrStyle, Attribute};
use rustc_lint::{LateContext, LintContext};
use rustc_resolve::rustdoc::DocFragmentKind;

use std::ops::Range;

use super::{DOC_SUSPICIOUS_FOOTNOTES, Fragments};

pub fn check(cx: &LateContext<'_>, doc: &str, range: Range<usize>, fragments: &Fragments<'_>, attrs: &[Attribute]) {
for i in doc[range.clone()]
.bytes()
.enumerate()
.filter_map(|(i, c)| if c == b'[' { Some(i) } else { None })
{
let start = i + range.start;
if doc.as_bytes().get(start + 1) == Some(&b'^')
&& let Some(end) = all_numbers_upto_brace(doc, start + 2)
&& doc.as_bytes().get(end) != Some(&b':')
&& doc.as_bytes().get(start - 1) != Some(&b'\\')
&& let Some(this_fragment) = {
// the `doc` string contains all fragments concatenated together
// figure out which one this suspicious footnote comes from
let mut starting_position = 0;
let mut found_fragment = fragments.fragments.last();
for fragment in fragments.fragments {
if start >= starting_position && start < starting_position + fragment.doc.as_str().len() {
found_fragment = Some(fragment);
break;
}
starting_position += fragment.doc.as_str().len();
}
found_fragment
}
{
let span = fragments.span(cx, start..end).unwrap_or(this_fragment.span);
span_lint_and_then(
cx,
DOC_SUSPICIOUS_FOOTNOTES,
span,
"looks like a footnote ref, but has no matching footnote",
|diag| {
if this_fragment.kind == DocFragmentKind::SugaredDoc {
let (doc_attr, (_, doc_attr_comment_kind)) = attrs
.iter()
.filter(|attr| attr.span().overlaps(this_fragment.span))
.rev()
.find_map(|attr| Some((attr, attr.doc_str_and_comment_kind()?)))
.unwrap();
let (to_add, terminator) = match (doc_attr_comment_kind, doc_attr.style()) {
(CommentKind::Line, AttrStyle::Outer) => ("\n///\n/// ", ""),
(CommentKind::Line, AttrStyle::Inner) => ("\n//!\n//! ", ""),
(CommentKind::Block, AttrStyle::Outer) => ("\n/** ", " */"),
(CommentKind::Block, AttrStyle::Inner) => ("\n/*! ", " */"),
};
diag.span_suggestion_verbose(
doc_attr.span().shrink_to_hi(),
"add footnote definition",
format!(
"{to_add}{label}: <!-- description -->{terminator}",
label = &doc[start..end]
),
Applicability::HasPlaceholders,
);
} else {
let is_file_include = cx
.sess()
.source_map()
.span_to_snippet(this_fragment.span)
.as_ref()
.map(|vdoc| vdoc.trim())
== Ok(doc);
if is_file_include {
// if this is a file include, then there's no quote marks
diag.span_suggestion_verbose(
this_fragment.span.shrink_to_hi(),
"add footnote definition",
format!("\n\n{label}: <!-- description -->", label = &doc[start..end],),
Applicability::HasPlaceholders,
);
} else {
// otherwise, we wrap in a string
diag.span_suggestion_verbose(
this_fragment.span,
"add footnote definition",
format!(
"r#\"{doc}\n\n{label}: <!-- description -->\"#",
doc = this_fragment.doc,
label = &doc[start..end],
),
Applicability::HasPlaceholders,
);
}
}
},
);
}
}
}

fn all_numbers_upto_brace(text: &str, i: usize) -> Option<usize> {
for (j, c) in text.as_bytes()[i..].iter().copied().enumerate().take(64) {
if c == b']' && j != 0 {
return Some(i + j + 1);
}
if !c.is_ascii_digit() || j >= 64 {
break;
}
}
None
}
40 changes: 38 additions & 2 deletions clippy_lints/src/doc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use std::ops::Range;
use url::Url;

mod doc_comment_double_space_linebreaks;
mod doc_suspicious_footnotes;
mod include_in_doc_without_cfg;
mod lazy_continuation;
mod link_with_quotes;
Expand Down Expand Up @@ -607,6 +608,37 @@ declare_clippy_lint! {
"double-space used for doc comment linebreak instead of `\\`"
}

declare_clippy_lint! {
/// ### What it does
/// Detects syntax that looks like a footnote reference.
///
/// Rustdoc footnotes are compatible with GitHub-Flavored Markdown (GFM).
/// GFM does not parse a footnote reference unless its definition also
/// exists. This lint checks for footnote references with missing
/// definitions, unless it thinks you're writing a regex.
///
/// ### Why is this bad?
/// This probably means that a footnote was meant to exist,
/// but was not written.
///
/// ### Example
/// ```no_run
/// /// This is not a footnote[^1], because no definition exists.
/// fn my_fn() {}
/// ```
/// Use instead:
/// ```no_run
/// /// This is a footnote[^1].
/// ///
/// /// [^1]: defined here
/// fn my_fn() {}
/// ```
#[clippy::version = "1.88.0"]
pub DOC_SUSPICIOUS_FOOTNOTES,
suspicious,
"looks like a link or footnote ref, but with no definition"
}

pub struct Documentation {
valid_idents: FxHashSet<String>,
check_private_items: bool,
Expand Down Expand Up @@ -638,7 +670,8 @@ impl_lint_pass!(Documentation => [
DOC_OVERINDENTED_LIST_ITEMS,
TOO_LONG_FIRST_DOC_PARAGRAPH,
DOC_INCLUDE_WITHOUT_CFG,
DOC_COMMENT_DOUBLE_SPACE_LINEBREAKS
DOC_COMMENT_DOUBLE_SPACE_LINEBREAKS,
DOC_SUSPICIOUS_FOOTNOTES,
]);

impl EarlyLintPass for Documentation {
Expand Down Expand Up @@ -825,6 +858,7 @@ fn check_attrs(cx: &LateContext<'_>, valid_idents: &FxHashSet<String>, attrs: &[
doc: &doc,
fragments: &fragments,
},
attrs,
))
}

Expand Down Expand Up @@ -905,6 +939,7 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
events: Events,
doc: &str,
fragments: Fragments<'_>,
attrs: &[Attribute],
) -> DocHeaders {
// true if a safety header was found
let mut headers = DocHeaders::default();
Expand Down Expand Up @@ -1148,7 +1183,8 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
// Don't check the text associated with external URLs
continue;
}
text_to_check.push((text, range, code_level));
text_to_check.push((text, range.clone(), code_level));
doc_suspicious_footnotes::check(cx, doc, range, &fragments, attrs);
}
}
FootnoteReference(_) => {}
Expand Down
186 changes: 186 additions & 0 deletions tests/ui/doc_suspicious_footnotes.fixed
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#![warn(clippy::doc_suspicious_footnotes)]
#![allow(clippy::needless_raw_string_hashes)]
//! This is not a footnote[^1].
//!
//! [^1]: <!-- description -->
//~^ doc_suspicious_footnotes
//!
//! This is not a footnote[^either], but it doesn't warn.
//!
//! This is not a footnote\[^1], but it also doesn't warn.
//!
//! This is not a footnote[^1\], but it also doesn't warn.
//!
//! This is not a `footnote[^1]`, but it also doesn't warn.
//!
//! This is a footnote[^2].
//!
//! [^2]: hello world

/// This is not a footnote[^1].
///
/// [^1]: <!-- description -->
//~^ doc_suspicious_footnotes
///
/// This is not a footnote[^either], but it doesn't warn.
///
/// This is not a footnote\[^1], but it also doesn't warn.
///
/// This is not a footnote[^1\], but it also doesn't warn.
///
/// This is not a `footnote[^1]`, but it also doesn't warn.
///
/// This is a footnote[^2].
///
/// [^2]: hello world
pub fn footnotes() {
// test code goes here
}

pub struct Foo;
#[rustfmt::skip]
impl Foo {
#[doc = r#"This is not a footnote[^1].

[^1]: <!-- description -->"#]
//~^ doc_suspicious_footnotes
#[doc = r#""#]
#[doc = r#"This is not a footnote[^either], but it doesn't warn."#]
#[doc = r#""#]
#[doc = r#"This is not a footnote\[^1], but it also doesn't warn."#]
#[doc = r#""#]
#[doc = r#"This is not a footnote[^1\], but it also doesn't warn."#]
#[doc = r#""#]
#[doc = r#"This is not a `footnote[^1]`, but it also doesn't warn."#]
#[doc = r#""#]
#[doc = r#"This is a footnote[^2]."#]
#[doc = r#""#]
#[doc = r#"[^2]: hello world"#]
pub fn footnotes() {
// test code goes here
}
#[doc = r#"This is not a footnote[^1].

This is not a footnote[^either], but it doesn't warn.

This is not a footnote\[^1], but it also doesn't warn.

This is not a footnote[^1\], but it also doesn't warn.

This is not a `footnote[^1]`, but it also doesn't warn.

This is a footnote[^2].

[^2]: hello world


[^1]: <!-- description -->"#]
//~^^^^^^^^^^^^^^ doc_suspicious_footnotes
pub fn footnotes2() {
// test code goes here
}
#[cfg_attr(
not(FALSE),
doc = r#"This is not a footnote[^1].

This is not a footnote[^either], but it doesn't warn.

[^1]: <!-- description -->"#
//~^ doc_suspicious_footnotes
)]
pub fn footnotes3() {
// test code goes here
}
#[doc = "My footnote [^foot\note]"]
pub fn footnote4() {
// test code goes here
}
#[doc = "Hihi"]pub fn footnote5() {
// test code goes here
}
}

#[doc = r#"This is not a footnote[^1].

[^1]: <!-- description -->"#]
//~^ doc_suspicious_footnotes
#[doc = r""]
#[doc = r"This is not a footnote[^either], but it doesn't warn."]
#[doc = r""]
#[doc = r"This is not a footnote\[^1], but it also doesn't warn."]
#[doc = r""]
#[doc = r"This is not a footnote[^1\], but it also doesn't warn."]
#[doc = r""]
#[doc = r"This is not a `footnote[^1]`, but it also doesn't warn."]
#[doc = r""]
#[doc = r"This is a footnote[^2]."]
#[doc = r""]
#[doc = r"[^2]: hello world"]
pub fn footnotes_attrs() {
// test code goes here
}

pub mod multiline {
/*!
* This is not a footnote[^1]. //~ doc_suspicious_footnotes
*
* This is not a footnote\[^1], but it doesn't warn.
*
* This is a footnote[^2].
*
* These give weird results, but correct ones, so it works.
*
* [^2]: hello world
*/
/*! [^1]: <!-- description --> */
/**
* This is not a footnote[^1]. //~ doc_suspicious_footnotes
*
* This is not a footnote\[^1], but it doesn't warn.
*
* This is a footnote[^2].
*
* These give weird results, but correct ones, so it works.
*
* [^2]: hello world
*/
/** [^1]: <!-- description --> */
pub fn foo() {}
}

/// This is not a footnote [^1]
///
/// [^1]: <!-- description -->
//~^ doc_suspicious_footnotes
///
/// This one is [^2]
///
/// [^2]: contents
#[doc = r#"This is not a footnote [^3]

[^3]: <!-- description -->"#]
//~^ doc_suspicious_footnotes
#[doc = ""]
#[doc = "This one is [^4]"]
#[doc = ""]
#[doc = "[^4]: contents"]
pub struct MultiFragmentFootnote;

#[doc(inline)]
/// This is not a footnote [^5]
///
/// [^5]: <!-- description -->
//~^ doc_suspicious_footnotes
///
/// This one is [^6]
///
/// [^6]: contents
#[doc = r#"This is not a footnote [^7]

[^7]: <!-- description -->"#]
//~^ doc_suspicious_footnotes
#[doc = ""]
#[doc = "This one is [^8]"]
#[doc = ""]
#[doc = "[^8]: contents"]
pub use MultiFragmentFootnote as OtherInlinedFootnote;
Loading