From b260e2fc4dcb3f24f8944139be0a973ef8cd248e Mon Sep 17 00:00:00 2001 From: Michael Howell Date: Tue, 1 Sep 2026 22:49:05 -0700 Subject: [PATCH 1/4] rustdoc: detect docs that use re-export concat This is a prereq for implementing LaTeX math. Because I want to be able to enable it on a per-docblock basis, re-exports need to be parsed with a different set of markdown parsing extensions than the items they link to, which means we can't just concatenate the strings and parse. In any case, markdown that does this would be hard to understand. We should probably lint on it anyway. --- compiler/rustc_resolve/src/rustdoc.rs | 18 +++ src/librustdoc/lint.rs | 10 +- src/librustdoc/passes/lint.rs | 4 + .../passes/lint/unportable_markdown.rs | 105 ++++++++++++++++++ .../lints/unportable-markdown-item-id.rs | 17 +++ .../lints/unportable-markdown-item-id.stderr | 41 +++++++ 6 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 src/librustdoc/passes/lint/unportable_markdown.rs create mode 100644 tests/rustdoc-ui/lints/unportable-markdown-item-id.rs create mode 100644 tests/rustdoc-ui/lints/unportable-markdown-item-id.stderr diff --git a/compiler/rustc_resolve/src/rustdoc.rs b/compiler/rustc_resolve/src/rustdoc.rs index ddf5600f76659..724097ef5540c 100644 --- a/compiler/rustc_resolve/src/rustdoc.rs +++ b/compiler/rustc_resolve/src/rustdoc.rs @@ -520,6 +520,24 @@ pub fn span_of_fragments(fragments: &[DocFragment]) -> Option { Some(first_fragment.span.to(last_fragment.span)) } +/// Matches a range of bytes from parsed markdown to the item it comes from. +/// +/// Returns `None` if the DocFragment itself has no attached `item_id`, +/// and, if that happens, the ID of the Item itself should be used. +pub fn item_defid_for_markdown_position( + mut md_pos: usize, + fragments: &[DocFragment], +) -> Option { + for frag in fragments { + let s = frag.doc.as_str(); + if md_pos <= s.len() { + return frag.item_id; + } + md_pos -= s.len() + } + None +} + /// Attempts to match a range of bytes from parsed markdown to a `Span` in the source code. /// /// This method does not always work, because markdown bytes don't necessarily match source bytes, diff --git a/src/librustdoc/lint.rs b/src/librustdoc/lint.rs index 5d8675aecb86a..a02f46af24631 100644 --- a/src/librustdoc/lint.rs +++ b/src/librustdoc/lint.rs @@ -220,6 +220,14 @@ declare_rustdoc_lint! { "detects unescaped pipe in table rows in doc comments" } +declare_rustdoc_lint! { + /// Detects if re-export doc comments are used in a way that might break. + /// This is a compatibility lint, and will eventually be removed. + UNPORTABLE_MARKDOWN, + Warn, + "detects Markdown that may parse differently in a new version" +} + pub(crate) static RUSTDOC_LINTS: Lazy> = Lazy::new(|| { vec![ BROKEN_INTRA_DOC_LINKS, @@ -236,6 +244,7 @@ pub(crate) static RUSTDOC_LINTS: Lazy> = Lazy::new(|| { BROKEN_FOOTNOTE, UNUSED_FOOTNOTE_DEFINITION, INVALID_MARKDOWN_TABLE, + UNPORTABLE_MARKDOWN, ] }); @@ -259,5 +268,4 @@ pub(crate) fn register_lints(_sess: &Session, lint_store: &mut LintStore) { .register_renamed("intra_doc_link_resolution_failure", "rustdoc::broken_intra_doc_links"); lint_store.register_renamed("non_autolinks", "rustdoc::bare_urls"); lint_store.register_renamed("rustdoc::non_autolinks", "rustdoc::bare_urls"); - lint_store.register_removed("rustdoc::unportable_markdown", "old parser removed"); } diff --git a/src/librustdoc/passes/lint.rs b/src/librustdoc/passes/lint.rs index 9988aa683d6d6..b825b6c271797 100644 --- a/src/librustdoc/passes/lint.rs +++ b/src/librustdoc/passes/lint.rs @@ -7,6 +7,7 @@ mod html_tags; mod invalid_markdown_table; mod redundant_explicit_links; mod unescaped_backticks; +mod unportable_markdown; use crate::clean::*; use crate::core::DocContext; @@ -51,6 +52,9 @@ impl DocVisitor<'_> for Linter<'_, '_> { if may_have_table { invalid_markdown_table::visit_item(self.cx, item, hir_id, &dox); } + if item.inner.attrs.doc_strings.iter().any(|frag| frag.item_id.is_some()) { + unportable_markdown::visit_item(self.cx, item, hir_id, &dox); + } } self.visit_item_recur(item) diff --git a/src/librustdoc/passes/lint/unportable_markdown.rs b/src/librustdoc/passes/lint/unportable_markdown.rs new file mode 100644 index 0000000000000..ee77614de3e4e --- /dev/null +++ b/src/librustdoc/passes/lint/unportable_markdown.rs @@ -0,0 +1,105 @@ +use rustc_errors::{Diag, DiagDecorator}; +use rustc_hir::HirId; +use rustc_resolve::rustdoc::pulldown_cmark::{Event, LinkType, Parser, Tag}; +use rustc_resolve::rustdoc::{item_defid_for_markdown_position, source_span_for_markdown_range}; + +use crate::clean::Item; +use crate::core::DocContext; +use crate::html::markdown::main_body_opts; + +pub(crate) fn visit_item(cx: &DocContext<'_>, item: &Item, hir_id: HirId, dox: &str) { + let Some(span) = item.span(cx.tcx) else { return }; + let mut p = Parser::new_ext(dox, main_body_opts()).into_offset_iter(); + while let Some((event, range)) = p.next() { + let span = source_span_for_markdown_range( + cx.tcx, + &dox, + &(range.start..range.start + 1), + &item.attrs.doc_strings, + ) + .map(|(span, _)| span) + .unwrap_or(span.inner()); + let item_id_start = item_defid_for_markdown_position(range.start, &item.attrs.doc_strings); + let item_id_end = item_defid_for_markdown_position(range.end - 1, &item.attrs.doc_strings); + if item_id_start != item_id_end { + cx.tcx.emit_node_span_lint( + crate::lint::UNPORTABLE_MARKDOWN, + hir_id, + span, + DiagDecorator(|lint: &mut Diag<'_, ()>| { + lint.primary_message("markdown element starts on one item and ends on another"); + lint.help("the way this is parsed might change in the future"); + report_idx(cx, item, dox, range.start, "starts", lint); + report_idx(cx, item, dox, range.end - 1, "ends", lint); + }), + ); + } else if let Event::Start(Tag::Link { + link_type: + LinkType::Reference + | LinkType::ReferenceUnknown + | LinkType::Collapsed + | LinkType::CollapsedUnknown + | LinkType::Shortcut + | LinkType::ShortcutUnknown, + id, + .. + }) = event + { + if let Some(refdef) = p.reference_definitions().get(&id[..]) { + let item_id_refdef_start = + item_defid_for_markdown_position(refdef.span.start, &item.attrs.doc_strings); + let item_id_refdef_end = + item_defid_for_markdown_position(refdef.span.end - 1, &item.attrs.doc_strings); + if item_id_refdef_start != item_id_start { + cx.tcx.emit_node_span_lint( + crate::lint::UNPORTABLE_MARKDOWN, + hir_id, + span, + DiagDecorator(|lint: &mut Diag<'_, ()>| { + lint.primary_message( + "markdown link and refdef are defined on different items", + ); + lint.help("the way this is parsed might change in the future"); + report_idx(cx, item, dox, refdef.span.start, "refdef starts", lint); + report_idx(cx, item, dox, range.start, "item starts", lint); + }), + ); + } else if item_id_refdef_end != item_id_start { + cx.tcx.emit_node_span_lint( + crate::lint::UNPORTABLE_MARKDOWN, + hir_id, + span, + DiagDecorator(|lint: &mut Diag<'_, ()>| { + lint.primary_message( + "markdown link and refdef are defined on different items", + ); + lint.help("the way this is parsed might change in the future"); + report_idx(cx, item, dox, refdef.span.end - 1, "refdef ends", lint); + report_idx(cx, item, dox, range.start, "item starts", lint); + }), + ); + } + } + } + } +} + +fn report_idx( + cx: &DocContext<'_>, + item: &Item, + dox: &str, + idx: usize, + verb: &'static str, + lint: &mut Diag<'_, ()>, +) { + if let Some((span, _)) = + source_span_for_markdown_range(cx.tcx, &dox, &(idx..idx + 1), &item.attrs.doc_strings) + { + lint.span_label(span, format!("{verb} here")); + } else { + let line_start = dox[..idx].rfind('\n').map_or(0, |i| i + 1); + let line_end = dox[idx..].find('\n').map_or(dox.len(), |i| i + idx); + let line = &dox[line_start..line_end]; + lint.help(format!("{verb} near `{line}`")); + } +} diff --git a/tests/rustdoc-ui/lints/unportable-markdown-item-id.rs b/tests/rustdoc-ui/lints/unportable-markdown-item-id.rs new file mode 100644 index 0000000000000..11eca86563b5c --- /dev/null +++ b/tests/rustdoc-ui/lints/unportable-markdown-item-id.rs @@ -0,0 +1,17 @@ +#![deny(rustdoc::unportable_markdown)] +// invalid_html is buggy in this case +#![allow(rustdoc::invalid_html_tags)] + +/// [doc.example]: https://example.com +/// +/// - bar +//~^ ERROR +//~| ERROR +#[doc(inline)] +pub use foo::First; + +pub mod foo { +/// - My [doc.example] +//~^ ERROR +pub struct First; +} diff --git a/tests/rustdoc-ui/lints/unportable-markdown-item-id.stderr b/tests/rustdoc-ui/lints/unportable-markdown-item-id.stderr new file mode 100644 index 0000000000000..973e3c2809be6 --- /dev/null +++ b/tests/rustdoc-ui/lints/unportable-markdown-item-id.stderr @@ -0,0 +1,41 @@ +error: markdown element starts on one item and ends on another + --> $DIR/unportable-markdown-item-id.rs:7:5 + | +LL | /// - bar + | ^ starts here +... +LL | /// - My [doc.example] + | - ends here + | + = help: the way this is parsed might change in the future +note: the lint level is defined here + --> $DIR/unportable-markdown-item-id.rs:1:9 + | +LL | #![deny(rustdoc::unportable_markdown)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: markdown link and refdef are defined on different items + --> $DIR/unportable-markdown-item-id.rs:14:10 + | +LL | /// [doc.example]: https://example.com + | - refdef starts here +... +LL | /// - My [doc.example] + | ^ item starts here + | + = help: the way this is parsed might change in the future + +error: markdown element starts on one item and ends on another + --> $DIR/unportable-markdown-item-id.rs:7:5 + | +LL | /// - bar + | ^ starts here +... +LL | /// - My [doc.example] + | - ends here + | + = help: the way this is parsed might change in the future + = note: duplicate diagnostic emitted due to `-Z deduplicate-diagnostics=no` + +error: aborting due to 3 previous errors + From c9f28f6324deb4e5d4213607434f9b14b8fe2200 Mon Sep 17 00:00:00 2001 From: Michael Howell Date: Wed, 2 Sep 2026 05:16:47 -0700 Subject: [PATCH 2/4] Clean up true positive lints on tests I want to catch false positives before running crater, but it seems like all of these really are re-exports. --- tests/rustdoc-html/attributes-inlining-108281.rs | 1 + tests/rustdoc-html/intra-doc/nested-use.rs | 1 + tests/rustdoc-html/multiple-import-levels.rs | 1 + .../private/inline-private-with-intermediate-doc-hidden.rs | 1 + tests/rustdoc-html/reexport/doc-hidden-reexports-109449.rs | 1 + tests/rustdoc-html/reexport/extern-135092.rs | 1 + tests/rustdoc-html/reexport/local-reexport-doc.rs | 1 + tests/rustdoc-html/reexport/merge-glob-and-non-glob.rs | 1 + 8 files changed, 8 insertions(+) diff --git a/tests/rustdoc-html/attributes-inlining-108281.rs b/tests/rustdoc-html/attributes-inlining-108281.rs index 9dfaf1a6846f3..112f662f54355 100644 --- a/tests/rustdoc-html/attributes-inlining-108281.rs +++ b/tests/rustdoc-html/attributes-inlining-108281.rs @@ -2,6 +2,7 @@ // It ensures that the attributes on the first reexport are not duplicated. #![crate_name = "foo"] +#![allow(rustdoc::unportable_markdown)] //@ has 'foo/index.html' diff --git a/tests/rustdoc-html/intra-doc/nested-use.rs b/tests/rustdoc-html/intra-doc/nested-use.rs index 2aaaed196fd12..88a1bc7f2a52f 100644 --- a/tests/rustdoc-html/intra-doc/nested-use.rs +++ b/tests/rustdoc-html/intra-doc/nested-use.rs @@ -1,6 +1,7 @@ // Regression test for issue #113896: Intra-doc links on nested use items. #![crate_name = "foo"] +#![allow(rustdoc::unportable_markdown)] //@ has foo/struct.Foo.html //@ has - '//a[@href="struct.Foo.html"]' 'Foo' diff --git a/tests/rustdoc-html/multiple-import-levels.rs b/tests/rustdoc-html/multiple-import-levels.rs index ac00f1fbdfc24..2fb0596814883 100644 --- a/tests/rustdoc-html/multiple-import-levels.rs +++ b/tests/rustdoc-html/multiple-import-levels.rs @@ -2,6 +2,7 @@ // account. #![crate_name = "foo"] +#![allow(rustdoc::unportable_markdown)] mod a { /// 1 diff --git a/tests/rustdoc-html/private/inline-private-with-intermediate-doc-hidden.rs b/tests/rustdoc-html/private/inline-private-with-intermediate-doc-hidden.rs index d27ecbad1690e..f3ef9e2d440d8 100644 --- a/tests/rustdoc-html/private/inline-private-with-intermediate-doc-hidden.rs +++ b/tests/rustdoc-html/private/inline-private-with-intermediate-doc-hidden.rs @@ -3,6 +3,7 @@ // from the doc hidden re-export. #![crate_name = "foo"] +#![allow(rustdoc::unportable_markdown)] //@ has 'foo/index.html' // There should only be one struct displayed. diff --git a/tests/rustdoc-html/reexport/doc-hidden-reexports-109449.rs b/tests/rustdoc-html/reexport/doc-hidden-reexports-109449.rs index 78b9214300a95..d5b0504f4b05d 100644 --- a/tests/rustdoc-html/reexport/doc-hidden-reexports-109449.rs +++ b/tests/rustdoc-html/reexport/doc-hidden-reexports-109449.rs @@ -2,6 +2,7 @@ // . #![crate_name = "foo"] +#![allow(rustdoc::unportable_markdown)] mod private_module { #[doc(hidden)] diff --git a/tests/rustdoc-html/reexport/extern-135092.rs b/tests/rustdoc-html/reexport/extern-135092.rs index fb5c71d56d595..b61a6a6ffa266 100644 --- a/tests/rustdoc-html/reexport/extern-135092.rs +++ b/tests/rustdoc-html/reexport/extern-135092.rs @@ -2,6 +2,7 @@ // #![crate_name = "foo"] +#![allow(rustdoc::unportable_markdown)] mod native { extern "C" { diff --git a/tests/rustdoc-html/reexport/local-reexport-doc.rs b/tests/rustdoc-html/reexport/local-reexport-doc.rs index 6db8f999d29f6..2e53aacc17e31 100644 --- a/tests/rustdoc-html/reexport/local-reexport-doc.rs +++ b/tests/rustdoc-html/reexport/local-reexport-doc.rs @@ -2,6 +2,7 @@ // the reexport. #![crate_name = "foo"] +#![allow(rustdoc::unportable_markdown)] //@ has 'foo/fn.g.html' //@ has - '//*[@class="toggle top-doc"]/*[@class="docblock"]' \ diff --git a/tests/rustdoc-html/reexport/merge-glob-and-non-glob.rs b/tests/rustdoc-html/reexport/merge-glob-and-non-glob.rs index ff67859cb39af..6056fe70181f3 100644 --- a/tests/rustdoc-html/reexport/merge-glob-and-non-glob.rs +++ b/tests/rustdoc-html/reexport/merge-glob-and-non-glob.rs @@ -6,6 +6,7 @@ #![no_core] #![no_std] #![crate_name = "foo"] +#![allow(rustdoc::unportable_markdown)] // First we ensure we only have two items. //@ has 'foo/index.html' From 8bf2e2ac5b996a2e487186343f2106d566fccfb4 Mon Sep 17 00:00:00 2001 From: Michael Howell Date: Wed, 2 Sep 2026 07:50:25 -0700 Subject: [PATCH 3/4] Update import-inline-merge.rs --- tests/rustdoc-ui/intra-doc/import-inline-merge.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/rustdoc-ui/intra-doc/import-inline-merge.rs b/tests/rustdoc-ui/intra-doc/import-inline-merge.rs index 9307ec23b1e65..a15b835e7d6f5 100644 --- a/tests/rustdoc-ui/intra-doc/import-inline-merge.rs +++ b/tests/rustdoc-ui/intra-doc/import-inline-merge.rs @@ -3,6 +3,7 @@ //@ check-pass +#![allow(rustdoc::unportable_markdown)] #![allow(rustdoc::private_intra_doc_links)] mod m { From c61bcffa660ff1ff430116c67ab4949c10b86bfd Mon Sep 17 00:00:00 2001 From: Michael Howell Date: Wed, 2 Sep 2026 10:47:44 -0700 Subject: [PATCH 4/4] Update lint.rs --- src/librustdoc/lint.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/librustdoc/lint.rs b/src/librustdoc/lint.rs index a02f46af24631..4991a6c6830ea 100644 --- a/src/librustdoc/lint.rs +++ b/src/librustdoc/lint.rs @@ -224,7 +224,7 @@ declare_rustdoc_lint! { /// Detects if re-export doc comments are used in a way that might break. /// This is a compatibility lint, and will eventually be removed. UNPORTABLE_MARKDOWN, - Warn, + Deny, "detects Markdown that may parse differently in a new version" }