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 2 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
107 changes: 107 additions & 0 deletions clippy_lints/src/doc/doc_suspicious_footnotes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
use clippy_utils::diagnostics::span_lint_and_then;
use rustc_errors::Applicability;
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<'_>) {
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 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| {
let applicability = Applicability::HasPlaceholders;
let start_of_md_line = doc.as_bytes()[..start]
.iter()
.rposition(|&c| c == b'\n' || c == b'\r')
.unwrap_or(0);
let end_of_md_line = doc.as_bytes()[start..]
.iter()
.position(|&c| c == b'\n' || c == b'\r')
.unwrap_or(doc.len() - start)
+ start;
let span_md_line = fragments
.span(cx, start_of_md_line..end_of_md_line)
.unwrap_or(this_fragment.span);
let span_whole_line = cx.sess().source_map().span_extend_to_line(span_md_line);
if let Ok(mut pfx) = cx
.sess()
.source_map()
.span_to_snippet(span_whole_line.until(span_md_line))
&& let Ok(mut sfx) = cx
.sess()
.source_map()
.span_to_snippet(span_md_line.shrink_to_hi().until(span_whole_line.shrink_to_hi()))
{
let mut insert_before = String::new();
let mut insert_after = String::new();
let span = if this_fragment.kind == rustc_resolve::rustdoc::DocFragmentKind::RawDoc
&& (!pfx.is_empty() || !sfx.is_empty())
{
if (pfx.trim() == "#[doc=" || pfx.trim() == "#![doc=") && sfx.trim() == "]" {
// try to use per-line doc fragments if that's what the author did
pfx.push('"');
sfx.insert(0, '"');
span_whole_line.shrink_to_hi()
} else {
// otherwise, replace the whole line with the result
pfx = String::new();
sfx = String::new();
insert_before = format!(r#"r###"{}"#, this_fragment.doc);
r####""###"####.clone_into(&mut insert_after);
span_md_line
}
} else {
span_whole_line.shrink_to_hi()
};
Comment on lines +41 to +83
Copy link
Member

Choose a reason for hiding this comment

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

Understanding this is proving a little bit hard, but for finding where to put the new comment, I'm thinking that you could loop over the atributes of the item with tcx.hir().attrs(), finding the last one that fits is_doc_comment and then adding our suggestion to the end of that one (on a new line)

Also, I'm not sure how does this interact with cfg and cfg_attr, we should probably test that =^w^=

diag.span_suggestion_verbose(
span,
"add footnote definition",
format!("{insert_before}\n{pfx}{sfx}\n{pfx}{label}: <!-- description -->{sfx}\n{pfx}{sfx}{insert_after}", label = &doc[start..end]),
applicability,
);
}
},
);
}
}
}

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
}
44 changes: 42 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 @@ -1147,7 +1186,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);
Copy link
Member

Choose a reason for hiding this comment

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

I'm sure that you've already tried this, but is there a reason that you didn't use FootnoteReference from ķEvent? You would not have to do span magic and manually parse footnotes and it seems that this function doesn't actually use text

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The FootnoteReference event is only emitted if the code is actually a footnote. This lint fires for text that looks like a footnote reference but isn't.

}
}
FootnoteReference(_) => {}
Expand Down
119 changes: 119 additions & 0 deletions tests/ui/doc_suspicious_footnotes.fixed
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#![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;
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 = 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
}
Loading