diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b62c9a59aa5..2b4f4ede9216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/clippy_lints/src/declared_lints.rs b/clippy_lints/src/declared_lints.rs index 2cccd6ba2702..c1d8bcccac2b 100644 --- a/clippy_lints/src/declared_lints.rs +++ b/clippy_lints/src/declared_lints.rs @@ -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, diff --git a/clippy_lints/src/doc/doc_suspicious_footnotes.rs b/clippy_lints/src/doc/doc_suspicious_footnotes.rs new file mode 100644 index 000000000000..614939e28106 --- /dev/null +++ b/clippy_lints/src/doc/doc_suspicious_footnotes.rs @@ -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, 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}: {sfx}\n{pfx}{sfx}{insert_after}", label = &doc[start..end]), + applicability, + ); + } + }, + ); + } + } +} + +fn all_numbers_upto_brace(text: &str, i: usize) -> Option { + 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 +} diff --git a/clippy_lints/src/doc/mod.rs b/clippy_lints/src/doc/mod.rs index ab77edf1147c..70dc1aad53a9 100644 --- a/clippy_lints/src/doc/mod.rs +++ b/clippy_lints/src/doc/mod.rs @@ -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. + /// + /// 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, 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, Range {} diff --git a/tests/ui/doc_suspicious_footnotes.fixed b/tests/ui/doc_suspicious_footnotes.fixed new file mode 100644 index 000000000000..3b433a7419f9 --- /dev/null +++ b/tests/ui/doc_suspicious_footnotes.fixed @@ -0,0 +1,119 @@ +#![warn(clippy::doc_suspicious_footnotes)] +#![allow(clippy::needless_raw_string_hashes)] +//! This is not a footnote[^1]. +//! +//! [^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 + +/// This is not a footnote[^1]. +/// +/// [^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 +pub fn footnotes() { + // test code goes here +} + +pub struct Foo; +impl Foo { + #[doc = r###"This is not a footnote[^1]. + +[^1]: +"###] + //~^ 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]: +"###] + //~^^^^^^^^^^^^^^ 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]: +"### + //~^ doc_suspicious_footnotes + )] + pub fn footnotes3() { + // test code goes here + } +} + +#[doc = r###"This is not a footnote[^1]. + +[^1]: +"###] +//~^ 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 +} diff --git a/tests/ui/doc_suspicious_footnotes.rs b/tests/ui/doc_suspicious_footnotes.rs new file mode 100644 index 000000000000..75f5029a630b --- /dev/null +++ b/tests/ui/doc_suspicious_footnotes.rs @@ -0,0 +1,99 @@ +#![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 + +/// 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 +pub fn footnotes() { + // test code goes here +} + +pub struct Foo; +impl Foo { + #[doc = r#"This is not a footnote[^1]."#] + //~^ 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 = "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 + "] + //~^^^^^^^^^^^^^^ doc_suspicious_footnotes + pub fn footnotes2() { + // test code goes here + } + #[cfg_attr( + not(FALSE), + doc = "This is not a footnote[^1].\n\nThis is not a footnote[^either], but it doesn't warn." + //~^ doc_suspicious_footnotes + )] + pub fn footnotes3() { + // test code goes here + } +} + +#[doc = r"This is not a footnote[^1]."] +//~^ 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 +} diff --git a/tests/ui/doc_suspicious_footnotes.stderr b/tests/ui/doc_suspicious_footnotes.stderr new file mode 100644 index 000000000000..9b5a33ccbff1 --- /dev/null +++ b/tests/ui/doc_suspicious_footnotes.stderr @@ -0,0 +1,109 @@ +error: looks like a footnote ref, but has no matching footnote + --> tests/ui/doc_suspicious_footnotes.rs:3:27 + | +LL | //! This is not a footnote[^1]. + | ^^^^ + | + = note: `-D clippy::doc-suspicious-footnotes` implied by `-D warnings` + = help: to override `-D warnings` add `#[allow(clippy::doc_suspicious_footnotes)]` +help: add footnote definition + | +LL ~ //! This is not a footnote[^1]. +LL + //! +LL + //! [^1]: +LL + //! + | + +error: looks like a footnote ref, but has no matching footnote + --> tests/ui/doc_suspicious_footnotes.rs:18:27 + | +LL | /// This is not a footnote[^1]. + | ^^^^ + | +help: add footnote definition + | +LL ~ /// This is not a footnote[^1]. +LL + /// +LL + /// [^1]: +LL + /// + | + +error: looks like a footnote ref, but has no matching footnote + --> tests/ui/doc_suspicious_footnotes.rs:38:13 + | +LL | #[doc = r#"This is not a footnote[^1]."#] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: add footnote definition + | +LL ~ #[doc = r###"This is not a footnote[^1]. +LL + +LL + [^1]: +LL ~ "###] + | + +error: looks like a footnote ref, but has no matching footnote + --> tests/ui/doc_suspicious_footnotes.rs:55:13 + | +LL | #[doc = "This is not a footnote[^1]. + | _____________^ +LL | | +LL | | This is not a footnote[^either], but it doesn't warn. +... | +LL | | [^2]: hello world +LL | | "] + | |_____^ + | +help: add footnote definition + | +LL ~ #[doc = r###"This is not a footnote[^1]. +LL + +LL + This is not a footnote[^either], but it doesn't warn. +LL + +LL + This is not a footnote\[^1], but it also doesn't warn. +LL + +LL + This is not a footnote[^1\], but it also doesn't warn. +LL + +LL + This is not a `footnote[^1]`, but it also doesn't warn. +LL + +LL + This is a footnote[^2]. +LL + +LL + [^2]: hello world +LL + +LL + +LL + [^1]: +LL ~ "###] + | + +error: looks like a footnote ref, but has no matching footnote + --> tests/ui/doc_suspicious_footnotes.rs:75:15 + | +LL | doc = "This is not a footnote[^1].\n\nThis is not a footnote[^either], but it doesn't warn." + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: add footnote definition + | +LL ~ doc = r###"This is not a footnote[^1]. +LL + +LL + This is not a footnote[^either], but it doesn't warn. +LL + +LL + [^1]: +LL + "### + | + +error: looks like a footnote ref, but has no matching footnote + --> tests/ui/doc_suspicious_footnotes.rs:83:9 + | +LL | #[doc = r"This is not a footnote[^1]."] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: add footnote definition + | +LL ~ #[doc = r###"This is not a footnote[^1]. +LL + +LL + [^1]: +LL ~ "###] + | + +error: aborting due to 6 previous errors + diff --git a/tests/ui/doc_suspicious_footnotes_include.rs b/tests/ui/doc_suspicious_footnotes_include.rs new file mode 100644 index 000000000000..4f75ad94eafd --- /dev/null +++ b/tests/ui/doc_suspicious_footnotes_include.rs @@ -0,0 +1,4 @@ +//@ error-in-other-file: footnote +//@ no-rustfix +#![warn(clippy::doc_suspicious_footnotes)] +#![doc=include_str!("doc_suspicious_footnotes_include.txt")] diff --git a/tests/ui/doc_suspicious_footnotes_include.stderr b/tests/ui/doc_suspicious_footnotes_include.stderr new file mode 100644 index 000000000000..cf5ba9b71261 --- /dev/null +++ b/tests/ui/doc_suspicious_footnotes_include.stderr @@ -0,0 +1,17 @@ +error: looks like a footnote ref, but has no matching footnote + --> tests/ui/doc_suspicious_footnotes_include.txt:1:23 + | +LL | This is not a footnote[^1]. + | ^^^^ + | + = note: `-D clippy::doc-suspicious-footnotes` implied by `-D warnings` + = help: to override `-D warnings` add `#[allow(clippy::doc_suspicious_footnotes)]` +help: add footnote definition + | +LL ~ This is not a footnote[^1]. +LL + +LL + [^1]: + | + +error: aborting due to 1 previous error + diff --git a/tests/ui/doc_suspicious_footnotes_include.txt b/tests/ui/doc_suspicious_footnotes_include.txt new file mode 100644 index 000000000000..2a533e32c4a6 --- /dev/null +++ b/tests/ui/doc_suspicious_footnotes_include.txt @@ -0,0 +1,13 @@ +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