render intra-doc links in the #[deprectated] note

This commit is contained in:
Folkert de Vries 2026-01-06 12:04:38 +01:00
parent 64c78f6e74
commit 3be74a7441
No known key found for this signature in database
GPG key ID: 1F17F6FFD112B97C
11 changed files with 179 additions and 22 deletions

View file

@ -235,6 +235,34 @@ impl AttributeExt for Attribute {
}
}
fn deprecation_note(&self) -> Option<Symbol> {
match &self.kind {
AttrKind::Normal(normal) if normal.item.path == sym::deprecated => {
let meta = &normal.item;
// #[deprecated = "..."]
if let Some(s) = meta.value_str() {
return Some(s);
}
// #[deprecated(note = "...")]
if let Some(list) = meta.meta_item_list() {
for nested in list {
if let Some(mi) = nested.meta_item()
&& mi.path == sym::note
&& let Some(s) = mi.value_str()
{
return Some(s);
}
}
}
None
}
_ => None,
}
}
fn doc_resolution_scope(&self) -> Option<AttrStyle> {
match &self.kind {
AttrKind::DocComment(..) => Some(self.style),
@ -277,6 +305,7 @@ impl Attribute {
pub fn may_have_doc_links(&self) -> bool {
self.doc_str().is_some_and(|s| comments::may_have_doc_links(s.as_str()))
|| self.deprecation_note().is_some_and(|s| comments::may_have_doc_links(s.as_str()))
}
/// Extracts the MetaItem from inside this Attribute.
@ -873,6 +902,11 @@ pub trait AttributeExt: Debug {
/// * `#[doc(...)]` returns `None`.
fn doc_str(&self) -> Option<Symbol>;
/// Returns the deprecation note if this is deprecation attribute.
/// * `#[deprecated = "note"]` returns `Some("note")`.
/// * `#[deprecated(note = "note", ...)]` returns `Some("note")`.
fn deprecation_note(&self) -> Option<Symbol>;
fn is_proc_macro_attr(&self) -> bool {
[sym::proc_macro, sym::proc_macro_attribute, sym::proc_macro_derive]
.iter()

View file

@ -1400,6 +1400,14 @@ impl AttributeExt for Attribute {
}
}
#[inline]
fn deprecation_note(&self) -> Option<Symbol> {
match &self {
Attribute::Parsed(AttributeKind::Deprecation { deprecation, .. }) => deprecation.note,
_ => None,
}
}
fn is_automatically_derived_attr(&self) -> bool {
matches!(self, Attribute::Parsed(AttributeKind::AutomaticallyDerived(..)))
}

View file

@ -410,8 +410,17 @@ pub fn may_be_doc_link(link_type: LinkType) -> bool {
/// Simplified version of `preprocessed_markdown_links` from rustdoc.
/// Must return at least the same links as it, but may add some more links on top of that.
pub(crate) fn attrs_to_preprocessed_links<A: AttributeExt + Clone>(attrs: &[A]) -> Vec<Box<str>> {
let (doc_fragments, _) = attrs_to_doc_fragments(attrs.iter().map(|attr| (attr, None)), true);
let doc = prepare_to_doc_link_resolution(&doc_fragments).into_values().next().unwrap();
let (doc_fragments, other_attrs) =
attrs_to_doc_fragments(attrs.iter().map(|attr| (attr, None)), false);
let mut doc =
prepare_to_doc_link_resolution(&doc_fragments).into_values().next().unwrap_or_default();
for attr in other_attrs {
if let Some(note) = attr.deprecation_note() {
doc += note.as_str();
doc += "\n";
}
}
parse_links(&doc)
}

View file

@ -7,6 +7,7 @@ use std::{fmt, iter};
use arrayvec::ArrayVec;
use itertools::Either;
use rustc_abi::{ExternAbi, VariantIdx};
use rustc_ast::attr::AttributeExt;
use rustc_data_structures::fx::{FxHashSet, FxIndexMap, FxIndexSet};
use rustc_data_structures::thin_vec::ThinVec;
use rustc_hir::attrs::{AttributeKind, DeprecatedSince, Deprecation, DocAttribute};
@ -450,7 +451,16 @@ impl Item {
}
pub(crate) fn attr_span(&self, tcx: TyCtxt<'_>) -> rustc_span::Span {
let deprecation_notes = self
.attrs
.other_attrs
.iter()
.filter_map(|attr| attr.deprecation_note().map(|_| attr.span()));
span_of_fragments(&self.attrs.doc_strings)
.into_iter()
.chain(deprecation_notes)
.reduce(|a, b| a.to(b))
.unwrap_or_else(|| self.span(tcx).map_or(DUMMY_SP, |span| span.inner()))
}

View file

@ -113,6 +113,7 @@ pub(crate) struct MarkdownWithToc<'a> {
/// and includes no paragraph tags.
pub(crate) struct MarkdownItemInfo<'a> {
pub(crate) content: &'a str,
pub(crate) links: &'a [RenderedLink],
pub(crate) ids: &'a mut IdMap,
}
/// A tuple struct like `Markdown` that renders only the first paragraph.
@ -1463,18 +1464,27 @@ impl MarkdownWithToc<'_> {
}
impl<'a> MarkdownItemInfo<'a> {
pub(crate) fn new(content: &'a str, ids: &'a mut IdMap) -> Self {
Self { content, ids }
pub(crate) fn new(content: &'a str, links: &'a [RenderedLink], ids: &'a mut IdMap) -> Self {
Self { content, links, ids }
}
pub(crate) fn write_into(self, mut f: impl fmt::Write) -> fmt::Result {
let MarkdownItemInfo { content, ids } = self;
let MarkdownItemInfo { content: md, links, ids } = self;
// This is actually common enough to special-case
if content.is_empty() {
if md.is_empty() {
return Ok(());
}
let p = Parser::new_ext(content, main_body_opts()).into_offset_iter();
let replacer = move |broken_link: BrokenLink<'_>| {
links
.iter()
.find(|link| *link.original_text == *broken_link.reference)
.map(|link| (link.href.as_str().into(), link.tooltip.as_str().into()))
};
let p = Parser::new_with_broken_link_callback(md, main_body_opts(), Some(replacer));
let p = p.into_offset_iter();
// Treat inline HTML as plain text.
let p = p.map(|event| match event.0 {
@ -1484,6 +1494,7 @@ impl<'a> MarkdownItemInfo<'a> {
ids.handle_footnotes(|ids, existing_footnotes| {
let p = HeadingLinks::new(p, None, ids, HeadingOffset::H1);
let p = SpannedLinkReplacer::new(p, links);
let p = footnotes::Footnotes::new(p, existing_footnotes);
let p = TableWrapper::new(p.map(|(ev, _)| ev));
let p = p.filter(|event| {

View file

@ -471,7 +471,7 @@ fn test_markdown_html_escape() {
fn t(input: &str, expect: &str) {
let mut idmap = IdMap::new();
let mut output = String::new();
MarkdownItemInfo::new(input, &mut idmap).write_into(&mut output).unwrap();
MarkdownItemInfo::new(input, &[], &mut idmap).write_into(&mut output).unwrap();
assert_eq!(output, expect, "original: {}", input);
}

View file

@ -877,7 +877,8 @@ fn short_item_info(
if let Some(note) = note {
let note = note.as_str();
let mut id_map = cx.id_map.borrow_mut();
let html = MarkdownItemInfo::new(note, &mut id_map);
let links = item.links(cx);
let html = MarkdownItemInfo::new(note, &links, &mut id_map);
message.push_str(": ");
html.write_into(&mut message).unwrap();
}

View file

@ -7,6 +7,7 @@ use std::fmt::Display;
use std::mem;
use std::ops::Range;
use rustc_ast::attr::AttributeExt;
use rustc_ast::util::comments::may_have_doc_links;
use rustc_data_structures::fx::{FxHashMap, FxHashSet, FxIndexMap, FxIndexSet};
use rustc_data_structures::intern::Interned;
@ -1047,18 +1048,7 @@ impl LinkCollector<'_, '_> {
return;
}
// We want to resolve in the lexical scope of the documentation.
// In the presence of re-exports, this is not the same as the module of the item.
// Rather than merging all documentation into one, resolve it one attribute at a time
// so we know which module it came from.
for (item_id, doc) in prepare_to_doc_link_resolution(&item.attrs.doc_strings) {
if !may_have_doc_links(&doc) {
continue;
}
debug!("combined_docs={doc}");
// NOTE: if there are links that start in one crate and end in another, this will not resolve them.
// This is a degenerate case and it's not supported by rustdoc.
let item_id = item_id.unwrap_or_else(|| item.item_id.expect_def_id());
let mut insert_links = |item_id, doc: &str| {
let module_id = match self.cx.tcx.def_kind(item_id) {
DefKind::Mod if item.inner_docs(self.cx.tcx) => item_id,
_ => find_nearest_parent_module(self.cx.tcx, item_id).unwrap(),
@ -1074,6 +1064,35 @@ impl LinkCollector<'_, '_> {
.insert(link);
}
}
};
// We want to resolve in the lexical scope of the documentation.
// In the presence of re-exports, this is not the same as the module of the item.
// Rather than merging all documentation into one, resolve it one attribute at a time
// so we know which module it came from.
for (item_id, doc) in prepare_to_doc_link_resolution(&item.attrs.doc_strings) {
if !may_have_doc_links(&doc) {
continue;
}
debug!("combined_docs={doc}");
// NOTE: if there are links that start in one crate and end in another, this will not resolve them.
// This is a degenerate case and it's not supported by rustdoc.
let item_id = item_id.unwrap_or_else(|| item.item_id.expect_def_id());
insert_links(item_id, &doc)
}
// Also resolve links in the note text of `#[deprecated]`.
for attr in &item.attrs.other_attrs {
let Some(note_sym) = attr.deprecation_note() else { continue };
let note = note_sym.as_str();
if !may_have_doc_links(note) {
continue;
}
debug!("deprecated_note={note}");
insert_links(item.item_id.expect_def_id(), note)
}
}
@ -1086,7 +1105,7 @@ impl LinkCollector<'_, '_> {
/// FIXME(jynelson): this is way too many arguments
fn resolve_link(
&mut self,
dox: &String,
dox: &str,
item: &Item,
item_id: DefId,
module_id: DefId,

View file

@ -0,0 +1,12 @@
//@ has deprecated/struct.A.html '//a[@href="{{channel}}/core/ops/range/struct.Range.html#structfield.start"]' 'start'
//@ has deprecated/struct.B1.html '//a[@href="{{channel}}/std/io/error/enum.ErrorKind.html#variant.NotFound"]' 'not_found'
//@ has deprecated/struct.B2.html '//a[@href="{{channel}}/std/io/error/enum.ErrorKind.html#variant.NotFound"]' 'not_found'
#[deprecated = "[start][std::ops::Range::start]"]
pub struct A;
#[deprecated(since = "0.0.0", note = "[not_found][std::io::ErrorKind::NotFound]")]
pub struct B1;
#[deprecated(note = "[not_found][std::io::ErrorKind::NotFound]", since = "0.0.0")]
pub struct B2;

View file

@ -0,0 +1,10 @@
#![deny(rustdoc::broken_intra_doc_links)]
#[deprecated = "[broken cross-reference](TypeAlias::hoge)"] //~ ERROR
pub struct A;
#[deprecated(since = "0.0.0", note = "[broken cross-reference](TypeAlias::hoge)")] //~ ERROR
pub struct B1;
#[deprecated(note = "[broken cross-reference](TypeAlias::hoge)", since = "0.0.0")] //~ ERROR
pub struct B2;

View file

@ -0,0 +1,43 @@
error: unresolved link to `TypeAlias::hoge`
--> $DIR/deprecated.rs:3:1
|
LL | #[deprecated = "[broken cross-reference](TypeAlias::hoge)"]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: the link appears in this line:
[broken cross-reference](TypeAlias::hoge)
^^^^^^^^^^^^^^^
= note: no item named `TypeAlias` in scope
note: the lint level is defined here
--> $DIR/deprecated.rs:1:9
|
LL | #![deny(rustdoc::broken_intra_doc_links)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error: unresolved link to `TypeAlias::hoge`
--> $DIR/deprecated.rs:6:1
|
LL | #[deprecated(since = "0.0.0", note = "[broken cross-reference](TypeAlias::hoge)")]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: the link appears in this line:
[broken cross-reference](TypeAlias::hoge)
^^^^^^^^^^^^^^^
= note: no item named `TypeAlias` in scope
error: unresolved link to `TypeAlias::hoge`
--> $DIR/deprecated.rs:9:1
|
LL | #[deprecated(note = "[broken cross-reference](TypeAlias::hoge)", since = "0.0.0")]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: the link appears in this line:
[broken cross-reference](TypeAlias::hoge)
^^^^^^^^^^^^^^^
= note: no item named `TypeAlias` in scope
error: aborting due to 3 previous errors