rustdoc/passes/lint/
redundant_explicit_links.rs

1use std::ops::Range;
2
3use pulldown_cmark::{
4    BrokenLink, BrokenLinkCallback, CowStr, Event, LinkType, OffsetIter, Parser, Tag,
5};
6use rustc_ast::NodeId;
7use rustc_errors::SuggestionStyle;
8use rustc_hir::HirId;
9use rustc_hir::def::{DefKind, DocLinkResMap, Namespace, Res};
10use rustc_lint_defs::Applicability;
11use rustc_resolve::rustdoc::{prepare_to_doc_link_resolution, source_span_for_markdown_range};
12use rustc_span::Symbol;
13use rustc_span::def_id::DefId;
14
15use crate::clean::Item;
16use crate::clean::utils::{find_nearest_parent_module, inherits_doc_hidden};
17use crate::core::DocContext;
18use crate::html::markdown::main_body_opts;
19
20#[derive(Debug)]
21struct LinkData {
22    resolvable_link: Option<String>,
23    resolvable_link_range: Option<Range<usize>>,
24    display_link: String,
25}
26
27pub(crate) fn visit_item(cx: &DocContext<'_>, item: &Item, hir_id: HirId) {
28    let hunks = prepare_to_doc_link_resolution(&item.attrs.doc_strings);
29    for (item_id, doc) in hunks {
30        if let Some(item_id) = item_id.or(item.def_id())
31            && !doc.is_empty()
32        {
33            check_redundant_explicit_link_for_did(cx, item, item_id, hir_id, &doc);
34        }
35    }
36}
37
38fn check_redundant_explicit_link_for_did(
39    cx: &DocContext<'_>,
40    item: &Item,
41    did: DefId,
42    hir_id: HirId,
43    doc: &str,
44) {
45    let Some(local_item_id) = did.as_local() else {
46        return;
47    };
48
49    let is_hidden = !cx.render_options.document_hidden
50        && (item.is_doc_hidden() || inherits_doc_hidden(cx.tcx, local_item_id, None));
51    if is_hidden {
52        return;
53    }
54    let is_private = !cx.render_options.document_private
55        && !cx.cache.effective_visibilities.is_directly_public(cx.tcx, did);
56    if is_private {
57        return;
58    }
59
60    let module_id = match cx.tcx.def_kind(did) {
61        DefKind::Mod if item.inner_docs(cx.tcx) => did,
62        _ => find_nearest_parent_module(cx.tcx, did).unwrap(),
63    };
64
65    let Some(resolutions) =
66        cx.tcx.resolutions(()).doc_link_resolutions.get(&module_id.expect_local())
67    else {
68        // If there's no resolutions in this module,
69        // then we skip resolution querying to
70        // avoid from panicking.
71        return;
72    };
73
74    check_redundant_explicit_link(cx, item, hir_id, doc, resolutions);
75}
76
77fn check_redundant_explicit_link<'md>(
78    cx: &DocContext<'_>,
79    item: &Item,
80    hir_id: HirId,
81    doc: &'md str,
82    resolutions: &DocLinkResMap,
83) -> Option<()> {
84    let mut broken_line_callback = |link: BrokenLink<'md>| Some((link.reference, "".into()));
85    let mut offset_iter = Parser::new_with_broken_link_callback(
86        doc,
87        main_body_opts(),
88        Some(&mut broken_line_callback),
89    )
90    .into_offset_iter();
91
92    while let Some((event, link_range)) = offset_iter.next() {
93        if let Event::Start(Tag::Link { link_type, dest_url, .. }) = event {
94            let link_data = collect_link_data(&mut offset_iter);
95
96            if let Some(resolvable_link) = link_data.resolvable_link.as_ref() {
97                if &link_data.display_link.replace('`', "") != resolvable_link {
98                    // Skips if display link does not match to actual
99                    // resolvable link, usually happens if display link
100                    // has several segments, e.g.
101                    // [this is just an `Option`](Option)
102                    continue;
103                }
104            }
105
106            let explicit_link = dest_url.to_string();
107            let display_link = link_data.resolvable_link.clone()?;
108
109            if explicit_link.ends_with(&display_link) || display_link.ends_with(&explicit_link) {
110                match link_type {
111                    LinkType::Inline | LinkType::ReferenceUnknown => {
112                        check_inline_or_reference_unknown_redundancy(
113                            cx,
114                            item,
115                            hir_id,
116                            doc,
117                            resolutions,
118                            link_range,
119                            dest_url.to_string(),
120                            link_data,
121                            if link_type == LinkType::Inline { (b'(', b')') } else { (b'[', b']') },
122                        );
123                    }
124                    LinkType::Reference => {
125                        check_reference_redundancy(
126                            cx,
127                            item,
128                            hir_id,
129                            doc,
130                            resolutions,
131                            link_range,
132                            &dest_url,
133                            link_data,
134                        );
135                    }
136                    _ => {}
137                }
138            }
139        }
140    }
141
142    None
143}
144
145/// FIXME(ChAoSUnItY): Too many arguments.
146fn check_inline_or_reference_unknown_redundancy(
147    cx: &DocContext<'_>,
148    item: &Item,
149    hir_id: HirId,
150    doc: &str,
151    resolutions: &DocLinkResMap,
152    link_range: Range<usize>,
153    dest: String,
154    link_data: LinkData,
155    (open, close): (u8, u8),
156) -> Option<()> {
157    let (resolvable_link, resolvable_link_range) =
158        (&link_data.resolvable_link?, &link_data.resolvable_link_range?);
159    let (dest_res, display_res) =
160        (find_resolution(resolutions, &dest)?, find_resolution(resolutions, resolvable_link)?);
161
162    if dest_res == display_res {
163        let link_span =
164            match source_span_for_markdown_range(cx.tcx, doc, &link_range, &item.attrs.doc_strings)
165            {
166                Some((sp, from_expansion)) => {
167                    if from_expansion {
168                        return None;
169                    }
170                    sp
171                }
172                None => item.attr_span(cx.tcx),
173            };
174        let (explicit_span, false) = source_span_for_markdown_range(
175            cx.tcx,
176            doc,
177            &offset_explicit_range(doc, link_range, open, close),
178            &item.attrs.doc_strings,
179        )?
180        else {
181            // This `span` comes from macro expansion so skipping it.
182            return None;
183        };
184        let (display_span, false) = source_span_for_markdown_range(
185            cx.tcx,
186            doc,
187            resolvable_link_range,
188            &item.attrs.doc_strings,
189        )?
190        else {
191            // This `span` comes from macro expansion so skipping it.
192            return None;
193        };
194
195        cx.tcx.node_span_lint(crate::lint::REDUNDANT_EXPLICIT_LINKS, hir_id, explicit_span, |lint| {
196            lint.primary_message("redundant explicit link target")
197                .span_label(explicit_span, "explicit target is redundant")
198                .span_label(display_span, "because label contains path that resolves to same destination")
199                .note("when a link's destination is not specified,\nthe label is used to resolve intra-doc links")
200                .span_suggestion_with_style(link_span, "remove explicit link target", format!("[{}]", link_data.display_link), Applicability::MaybeIncorrect, SuggestionStyle::ShowAlways);
201        });
202    }
203
204    None
205}
206
207/// FIXME(ChAoSUnItY): Too many arguments.
208fn check_reference_redundancy(
209    cx: &DocContext<'_>,
210    item: &Item,
211    hir_id: HirId,
212    doc: &str,
213    resolutions: &DocLinkResMap,
214    link_range: Range<usize>,
215    dest: &CowStr<'_>,
216    link_data: LinkData,
217) -> Option<()> {
218    let (resolvable_link, resolvable_link_range) =
219        (&link_data.resolvable_link?, &link_data.resolvable_link_range?);
220    let (dest_res, display_res) =
221        (find_resolution(resolutions, dest)?, find_resolution(resolutions, resolvable_link)?);
222
223    if dest_res == display_res {
224        let link_span =
225            match source_span_for_markdown_range(cx.tcx, doc, &link_range, &item.attrs.doc_strings)
226            {
227                Some((sp, from_expansion)) => {
228                    if from_expansion {
229                        return None;
230                    }
231                    sp
232                }
233                None => item.attr_span(cx.tcx),
234            };
235        let (explicit_span, false) = source_span_for_markdown_range(
236            cx.tcx,
237            doc,
238            &offset_explicit_range(doc, link_range.clone(), b'[', b']'),
239            &item.attrs.doc_strings,
240        )?
241        else {
242            // This `span` comes from macro expansion so skipping it.
243            return None;
244        };
245        let (display_span, false) = source_span_for_markdown_range(
246            cx.tcx,
247            doc,
248            resolvable_link_range,
249            &item.attrs.doc_strings,
250        )?
251        else {
252            // This `span` comes from macro expansion so skipping it.
253            return None;
254        };
255        let (def_span, _) = source_span_for_markdown_range(
256            cx.tcx,
257            doc,
258            &offset_reference_def_range(doc, dest, link_range),
259            &item.attrs.doc_strings,
260        )?;
261
262        cx.tcx.node_span_lint(crate::lint::REDUNDANT_EXPLICIT_LINKS, hir_id, explicit_span, |lint| {
263            lint.primary_message("redundant explicit link target")
264            .span_label(explicit_span, "explicit target is redundant")
265                .span_label(display_span, "because label contains path that resolves to same destination")
266                .span_note(def_span, "referenced explicit link target defined here")
267                .note("when a link's destination is not specified,\nthe label is used to resolve intra-doc links")
268                .span_suggestion_with_style(link_span, "remove explicit link target", format!("[{}]", link_data.display_link), Applicability::MaybeIncorrect, SuggestionStyle::ShowAlways);
269        });
270    }
271
272    None
273}
274
275fn find_resolution(resolutions: &DocLinkResMap, path: &str) -> Option<Res<NodeId>> {
276    [Namespace::TypeNS, Namespace::ValueNS, Namespace::MacroNS]
277        .into_iter()
278        .find_map(|ns| resolutions.get(&(Symbol::intern(path), ns)).copied().flatten())
279}
280
281/// Collects all necessary data of link.
282fn collect_link_data<'input, F: BrokenLinkCallback<'input>>(
283    offset_iter: &mut OffsetIter<'input, F>,
284) -> LinkData {
285    let mut resolvable_link = None;
286    let mut resolvable_link_range = None;
287    let mut display_link = String::new();
288    let mut is_resolvable = true;
289
290    for (event, range) in offset_iter.by_ref() {
291        match event {
292            Event::Text(code) => {
293                let code = code.to_string();
294                display_link.push_str(&code);
295                resolvable_link = Some(code);
296                resolvable_link_range = Some(range);
297            }
298            Event::Code(code) => {
299                let code = code.to_string();
300                display_link.push('`');
301                display_link.push_str(&code);
302                display_link.push('`');
303                resolvable_link = Some(code);
304                resolvable_link_range = Some(range);
305            }
306            Event::Start(_) => {
307                // If there is anything besides backticks, it's not considered as an intra-doc link
308                // so we ignore it.
309                is_resolvable = false;
310            }
311            Event::End(_) => {
312                break;
313            }
314            _ => {}
315        }
316    }
317
318    if !is_resolvable {
319        resolvable_link_range = None;
320        resolvable_link = None;
321    }
322
323    LinkData { resolvable_link, resolvable_link_range, display_link }
324}
325
326fn offset_explicit_range(md: &str, link_range: Range<usize>, open: u8, close: u8) -> Range<usize> {
327    let mut open_brace = !0;
328    let mut close_brace = !0;
329    for (i, b) in md.as_bytes()[link_range.clone()].iter().copied().enumerate().rev() {
330        let i = i + link_range.start;
331        if b == close {
332            close_brace = i;
333            break;
334        }
335    }
336
337    if close_brace < link_range.start || close_brace >= link_range.end {
338        return link_range;
339    }
340
341    let mut nesting = 1;
342
343    for (i, b) in md.as_bytes()[link_range.start..close_brace].iter().copied().enumerate().rev() {
344        let i = i + link_range.start;
345        if b == close {
346            nesting += 1;
347        }
348        if b == open {
349            nesting -= 1;
350        }
351        if nesting == 0 {
352            open_brace = i;
353            break;
354        }
355    }
356
357    assert!(open_brace != close_brace);
358
359    if open_brace < link_range.start || open_brace >= link_range.end {
360        return link_range;
361    }
362    // do not actually include braces in the span
363    (open_brace + 1)..close_brace
364}
365
366fn offset_reference_def_range(
367    md: &str,
368    dest: &CowStr<'_>,
369    link_range: Range<usize>,
370) -> Range<usize> {
371    // For diagnostics, we want to underline the link's definition but `span` will point at
372    // where the link is used. This is a problem for reference-style links, where the definition
373    // is separate from the usage.
374
375    match dest {
376        // `Borrowed` variant means the string (the link's destination) may come directly from
377        // the markdown text and we can locate the original link destination.
378        // NOTE: LinkReplacer also provides `Borrowed` but possibly from other sources,
379        // so `locate()` can fall back to use `span`.
380        CowStr::Borrowed(s) => {
381            // FIXME: remove this function once pulldown_cmark can provide spans for link definitions.
382            unsafe {
383                let s_start = dest.as_ptr();
384                let s_end = s_start.add(s.len());
385                let md_start = md.as_ptr();
386                let md_end = md_start.add(md.len());
387                if md_start <= s_start && s_end <= md_end {
388                    let start = s_start.offset_from(md_start) as usize;
389                    let end = s_end.offset_from(md_start) as usize;
390                    start..end
391                } else {
392                    link_range
393                }
394            }
395        }
396
397        // For anything else, we can only use the provided range.
398        CowStr::Boxed(_) | CowStr::Inlined(_) => link_range,
399    }
400}