-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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() | ||
}; | ||
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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
|
@@ -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. | ||
/// | ||
notriddle marked this conversation as resolved.
Show resolved
Hide resolved
|
||
/// 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, | ||
|
@@ -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 { | ||
|
@@ -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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
} | ||
} | ||
FootnoteReference(_) => {} | ||
|
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 | ||
} |
There was a problem hiding this comment.
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 fitsis_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
andcfg_attr
, we should probably test that =^w^=