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

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
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 @@ -5636,6 +5636,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 @@ -117,6 +117,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
105 changes: 105 additions & 0 deletions clippy_lints/src/doc/doc_suspicious_footnotes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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 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;
let mut this_fragment_start = 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) = fragments
.fragments
.iter()
.find(|frag| {
let found = this_fragment_start < frag.doc.as_str().len();
if !found {
this_fragment_start -= frag.doc.as_str().len();
}
found
})
.or(fragments.fragments.last())
&& let Some((last_doc_attr, (last_doc_attr_str, last_doc_attr_comment_kind))) = attrs
.iter()
.rev()
.find_map(|attr| Some((attr, attr.doc_str_and_comment_kind()?)))
{
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 last_doc_attr.is_doc_comment() {
let (pfx, sfx) = match (last_doc_attr_comment_kind, last_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(
last_doc_attr.span().shrink_to_hi(),
"add footnote definition",
format!("{pfx}{label}: <!-- description -->{sfx}", 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 = last_doc_attr_str,
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
}
46 changes: 44 additions & 2 deletions clippy_lints/src/doc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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 @@ -608,6 +609,43 @@ 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,
/// because it matches the regexp `\[\^[0-9]+\]`,
/// but has no referent.
///
/// Rustdoc footnotes are compatible with GitHub-Flavored Markdown (GFM).
/// They are not parsed as footnotes unless a definition also exists,
/// so they usually "do what you mean" if you want to write the text
/// literally—usually in a regular expression.
///
/// However, footnote references are usually numbers, and regex
/// negative character classes usually contain other characters, so this
/// lint can make a practical guess for which is meant.
///
/// ### 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 @@ -639,7 +677,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 @@ -823,6 +862,7 @@ fn check_attrs(cx: &LateContext<'_>, valid_idents: &FxHashSet<String>, attrs: &[
doc: &doc,
fragments: &fragments,
},
attrs,
))
}

Expand Down Expand Up @@ -903,6 +943,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 @@ -1147,7 +1188,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
141 changes: 141 additions & 0 deletions tests/ui/doc_suspicious_footnotes.fixed
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#![warn(clippy::doc_suspicious_footnotes)]
#![allow(clippy::needless_raw_string_hashes)]
//! This is not a footnote[^1].
//~^ 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
//!
//! [^1]: <!-- description -->

/// This is not a footnote[^1].
//~^ 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
///
/// [^1]: <!-- description -->
pub fn footnotes() {
// test code goes here
}

pub struct Foo;
impl Foo {
#[doc = r#"[^2]: hello world

[^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 = r#"[^2]: hello world

[^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() {}
}
Loading