diff --git a/src/librustdoc/lint.rs b/src/librustdoc/lint.rs index 91f92b799889b..f3052dd9ead82 100644 --- a/src/librustdoc/lint.rs +++ b/src/librustdoc/lint.rs @@ -196,6 +196,20 @@ declare_rustdoc_lint! { "detects redundant explicit links in doc comments" } +declare_rustdoc_lint! { + /// This lint checks for uses of footnote references without definition. + BROKEN_FOOTNOTE, + Warn, + "detects footnote references with no associated definition" +} + +declare_rustdoc_lint! { + /// This lint checks if all footnote definitions are used. + UNUSED_FOOTNOTE_DEFINITION, + Warn, + "detects unused footnote definitions" +} + pub(crate) static RUSTDOC_LINTS: Lazy> = Lazy::new(|| { vec![ BROKEN_INTRA_DOC_LINKS, @@ -209,6 +223,8 @@ pub(crate) static RUSTDOC_LINTS: Lazy> = Lazy::new(|| { MISSING_CRATE_LEVEL_DOCS, UNESCAPED_BACKTICKS, REDUNDANT_EXPLICIT_LINKS, + BROKEN_FOOTNOTE, + UNUSED_FOOTNOTE_DEFINITION, ] }); diff --git a/src/librustdoc/passes/lint.rs b/src/librustdoc/passes/lint.rs index 7740d14148bf0..bb952b32393cf 100644 --- a/src/librustdoc/passes/lint.rs +++ b/src/librustdoc/passes/lint.rs @@ -3,6 +3,7 @@ mod bare_urls; mod check_code_block_syntax; +mod footnotes; mod html_tags; mod redundant_explicit_links; mod unescaped_backticks; @@ -41,6 +42,7 @@ impl DocVisitor<'_> for Linter<'_, '_> { if may_have_link { bare_urls::visit_item(self.cx, item, hir_id, &dox); redundant_explicit_links::visit_item(self.cx, item, hir_id); + footnotes::visit_item(self.cx, item, hir_id, &dox); } if may_have_code { check_code_block_syntax::visit_item(self.cx, item, &dox); diff --git a/src/librustdoc/passes/lint/footnotes.rs b/src/librustdoc/passes/lint/footnotes.rs new file mode 100644 index 0000000000000..e9b78b534d9c6 --- /dev/null +++ b/src/librustdoc/passes/lint/footnotes.rs @@ -0,0 +1,117 @@ +use std::ops::Range; + +use rustc_data_structures::fx::{FxHashMap, FxHashSet}; +use rustc_errors::DiagDecorator; +use rustc_hir::HirId; +use rustc_lint_defs::Applicability; +use rustc_resolve::rustdoc::pulldown_cmark::{Event, Options, Parser, Tag}; +use rustc_resolve::rustdoc::source_span_for_markdown_range; + +use crate::clean::Item; +use crate::core::DocContext; + +pub(crate) fn visit_item(cx: &DocContext<'_>, item: &Item, hir_id: HirId, dox: &str) { + let tcx = cx.tcx; + + let mut missing_footnote_references = FxHashSet::default(); + let mut footnote_references = FxHashSet::default(); + let mut footnote_definitions = FxHashMap::default(); + + let options = Options::ENABLE_FOOTNOTES; + let mut parser = Parser::new_ext(dox, options).into_offset_iter().peekable(); + while let Some((event, span)) = parser.next() { + match event { + Event::Text(text) + if &*text == "[" + && (span.start == 0 || dox.as_bytes().get(span.start - 1) != Some(&b'\\')) + && let Some(len) = scan_footnote_ref(&dox[span.start..]) => + { + missing_footnote_references + .insert(Range { start: span.start, end: span.start + len }); + } + Event::FootnoteReference(label) => { + footnote_references.insert(label); + } + Event::Start(Tag::FootnoteDefinition(label)) => { + footnote_definitions.insert(label, span.start + 1); + } + _ => {} + } + } + + #[allow(rustc::potential_query_instability)] + for (footnote, span) in footnote_definitions { + if !footnote_references.contains(&footnote) { + let (span, _) = source_span_for_markdown_range( + tcx, + dox, + &(span..span + 1), + &item.attrs.doc_strings, + ) + .unwrap_or_else(|| (item.attr_span(tcx), false)); + + tcx.emit_node_span_lint( + crate::lint::UNUSED_FOOTNOTE_DEFINITION, + hir_id, + span, + DiagDecorator(|lint| { + lint.primary_message("unused footnote definition"); + }), + ); + } + } + + #[allow(rustc::potential_query_instability)] + for span in missing_footnote_references { + let ref_span = source_span_for_markdown_range(tcx, dox, &span, &item.attrs.doc_strings) + .map(|(span, _)| span) + .unwrap_or_else(|| item.attr_span(tcx)); + + tcx.emit_node_span_lint( + crate::lint::BROKEN_FOOTNOTE, + hir_id, + ref_span, + DiagDecorator(|lint| { + lint.primary_message("no footnote definition matching this footnote"); + lint.span_suggestion( + ref_span.shrink_to_lo(), + "if it should not be a footnote, escape it", + "\\", + Applicability::MaybeIncorrect, + ); + }), + ); + } +} + +fn scan_footnote_ref(dox: &str) -> Option { + let dox = dox.as_bytes(); + let mut i = 0; + if dox.get(i) != Some(&b'[') { + return None; + } + i += 1; + if dox.get(i) != Some(&b'^') { + return None; + } + i += 1; + while let Some(&c) = dox.get(i) { + if c == b']' { + i += 1; + return Some(i); + } + if c == b'\r' || c == b'\n' || c == b'[' { + // Can't nest things like this. + break; + } + if c == b'\\' { + i += 1; + } + if dox.get(i) == Some(&b'\r') || dox.get(i) == Some(&b'\n') { + // Can't have line breaks in footnote refs + break; + } + i += 1; + } + None +} diff --git a/tests/rustdoc-ui/lints/broken-footnote.rs b/tests/rustdoc-ui/lints/broken-footnote.rs new file mode 100644 index 0000000000000..8a9a78f9cff45 --- /dev/null +++ b/tests/rustdoc-ui/lints/broken-footnote.rs @@ -0,0 +1,50 @@ +#![deny(rustdoc::broken_footnote)] + +//! Footnote referenced [^1]. And [^2]. And [^bla]. +//! +//! [^1]: footnote defined +//~^^^ ERROR: no footnote definition matching this footnote +//~| ERROR: no footnote definition matching this footnote + +// Should not lint. +//! foo[^1] +//! +//! ``` +//! +//! [^1]: bar +//! +//! ``` + +// Edge cases from https://pulldown-cmark.github.io/pulldown-cmark/specs/footnotes.html +/// The following are not footnote references: +/// +/// \[^a] +/// +/// [\^b] +/// +/// [^c\] +/// +/// [^d +/// e] +/// +/// [^f\ +/// g] +pub struct NotReferences; + +/// The following are not footnote references: +/// +/// [^a b] +//~^ ERROR: no footnote definition matching this footnote +pub struct EdgeCases1; + +/// Another: +/// +/// [^1\.2] +//~^ ERROR: no footnote definition matching this footnote +pub struct EdgeCases2; + +/// Last: +/// +/// [^*] +//~^ ERROR: no footnote definition matching this footnote +pub struct EdgeCases3; diff --git a/tests/rustdoc-ui/lints/broken-footnote.stderr b/tests/rustdoc-ui/lints/broken-footnote.stderr new file mode 100644 index 0000000000000..26f4c164862ec --- /dev/null +++ b/tests/rustdoc-ui/lints/broken-footnote.stderr @@ -0,0 +1,48 @@ +error: no footnote definition matching this footnote + --> $DIR/broken-footnote.rs:3:45 + | +LL | //! Footnote referenced [^1]. And [^2]. And [^bla]. + | -^^^^^ + | | + | help: if it should not be a footnote, escape it: `\` + | +note: the lint level is defined here + --> $DIR/broken-footnote.rs:1:9 + | +LL | #![deny(rustdoc::broken_footnote)] + | ^^^^^^^^^^^^^^^^^^^^^^^^ + +error: no footnote definition matching this footnote + --> $DIR/broken-footnote.rs:3:35 + | +LL | //! Footnote referenced [^1]. And [^2]. And [^bla]. + | -^^^ + | | + | help: if it should not be a footnote, escape it: `\` + +error: no footnote definition matching this footnote + --> $DIR/broken-footnote.rs:36:5 + | +LL | /// [^a b] + | -^^^^^ + | | + | help: if it should not be a footnote, escape it: `\` + +error: no footnote definition matching this footnote + --> $DIR/broken-footnote.rs:42:5 + | +LL | /// [^1\.2] + | -^^^^^^ + | | + | help: if it should not be a footnote, escape it: `\` + +error: no footnote definition matching this footnote + --> $DIR/broken-footnote.rs:48:5 + | +LL | /// [^*] + | -^^^ + | | + | help: if it should not be a footnote, escape it: `\` + +error: aborting due to 5 previous errors + diff --git a/tests/rustdoc-ui/lints/unused-footnote.rs b/tests/rustdoc-ui/lints/unused-footnote.rs new file mode 100644 index 0000000000000..a71e20ff6d500 --- /dev/null +++ b/tests/rustdoc-ui/lints/unused-footnote.rs @@ -0,0 +1,9 @@ +// This test ensures that the `rustdoc::unused_footnote` lint is working as expected. + +#![deny(rustdoc::unused_footnote_definition)] + +//! Footnote referenced. [^2] +//! +//! [^1]: footnote defined +//! [^2]: footnote defined +//~^^ ERROR: unused_footnote_definition diff --git a/tests/rustdoc-ui/lints/unused-footnote.stderr b/tests/rustdoc-ui/lints/unused-footnote.stderr new file mode 100644 index 0000000000000..d227cef181df3 --- /dev/null +++ b/tests/rustdoc-ui/lints/unused-footnote.stderr @@ -0,0 +1,14 @@ +error: unused footnote definition + --> $DIR/unused-footnote.rs:7:6 + | +LL | //! [^1]: footnote defined + | ^ + | +note: the lint level is defined here + --> $DIR/unused-footnote.rs:3:9 + | +LL | #![deny(rustdoc::unused_footnote_definition)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: aborting due to 1 previous error +