Skip to main content

rustdoc/passes/lint/
redundant_explicit_links.rs

1use std::ops::Range;
2
3use rustc_ast::NodeId;
4use rustc_errors::{Diag, DiagCtxtHandle, Diagnostic, Level, SuggestionStyle};
5use rustc_hir::HirId;
6use rustc_hir::def::{DefKind, Namespace, Res};
7use rustc_lint::Applicability;
8use rustc_middle::middle::resolve::DocLinkResMap;
9use rustc_resolve::rustdoc::pulldown_cmark::{
10    BrokenLink, BrokenLinkCallback, CowStr, Event, LinkType, OffsetIter, Parser, Tag,
11};
12use rustc_resolve::rustdoc::{prepare_to_doc_link_resolution, source_span_for_markdown_range};
13use rustc_span::def_id::{DefId, ModId};
14use rustc_span::{Span, Symbol};
15
16use crate::clean::utils::{find_nearest_parent_module, inherits_doc_hidden};
17use crate::clean::{Item, inline};
18use crate::core::DocContext;
19use crate::formats::item_type::ItemType;
20use crate::html::format::href_relative_parts;
21use crate::html::markdown::main_body_opts;
22
23#[derive(Debug)]
24struct LinkData {
25    resolvable_link: Option<String>,
26    resolvable_link_range: Option<Range<usize>>,
27    display_link: String,
28}
29
30pub(crate) fn visit_item(cx: &DocContext<'_>, item: &Item, hir_id: HirId) {
31    let hunks = prepare_to_doc_link_resolution(&item.attrs.doc_strings);
32    for (item_id, doc) in hunks {
33        if let Some(item_id) = item_id.or(item.def_id()) {
34            if !doc.outer.is_empty() || !doc.inner.is_empty() {
35                let doc = format!("{}{}", doc.outer, doc.inner);
36                check_redundant_explicit_link_for_did(cx, item, item_id, hir_id, &doc);
37            }
38        }
39    }
40}
41
42fn check_redundant_explicit_link_for_did(
43    cx: &DocContext<'_>,
44    item: &Item,
45    did: DefId,
46    hir_id: HirId,
47    doc: &str,
48) {
49    let Some(local_item_id) = did.as_local() else {
50        return;
51    };
52
53    let is_hidden = !cx.document_hidden()
54        && (item.is_doc_hidden() || inherits_doc_hidden(cx.tcx, local_item_id, None));
55    if is_hidden {
56        return;
57    }
58    let is_private =
59        !cx.document_private() && !cx.cache.effective_visibilities.is_directly_public(cx.tcx, did);
60    if is_private {
61        return;
62    }
63
64    let module_id = match cx.tcx.def_kind(did) {
65        DefKind::Mod if item.inner_docs(cx.tcx) => ModId::new_unchecked(did),
66        _ => find_nearest_parent_module(cx.tcx, did).unwrap(),
67    };
68
69    let Some(resolutions) =
70        cx.tcx.resolutions(()).doc_link_resolutions.get(&module_id.expect_local())
71    else {
72        // If there's no resolutions in this module,
73        // then we skip resolution querying to
74        // avoid from panicking.
75        return;
76    };
77
78    check_redundant_explicit_link(cx, item, module_id.into(), hir_id, doc, resolutions);
79}
80
81fn check_redundant_explicit_link<'md>(
82    cx: &DocContext<'_>,
83    item: &Item,
84    module_id: DefId,
85    hir_id: HirId,
86    doc: &'md str,
87    resolutions: &DocLinkResMap,
88) {
89    let mut broken_line_callback = |link: BrokenLink<'md>| Some((link.reference, "".into()));
90    let mut offset_iter = Parser::new_with_broken_link_callback(
91        doc,
92        main_body_opts(),
93        Some(&mut broken_line_callback),
94    )
95    .into_offset_iter();
96
97    while let Some((event, link_range)) = offset_iter.next() {
98        if let Event::Start(Tag::Link { link_type, dest_url, title, .. }) = event {
99            if !title.is_empty() {
100                // Skips if the link specifies a title, e.g. `[Option](Option "title")`,
101                // in which case the explicit link cannot be removed without also
102                // removing the title.
103                continue;
104            }
105
106            let link_data = collect_link_data(&mut offset_iter);
107
108            let Some(resolvable_link) = link_data.resolvable_link.as_ref() else {
109                // collect_link_data didn't return a resolvable_link
110                // most likely due to the displayed link containing inline markup
111                continue;
112            };
113
114            if &link_data.display_link.replace('`', "") != resolvable_link {
115                // Skips if display link does not match to actual
116                // resolvable link, usually happens if display link
117                // has several segments, e.g.
118                // [this is just an `Option`](Option)
119                continue;
120            }
121
122            let check_result = match link_type {
123                LinkType::Inline | LinkType::ReferenceUnknown => {
124                    check_inline_or_reference_unknown_redundancy(
125                        cx,
126                        item,
127                        module_id,
128                        hir_id,
129                        doc,
130                        resolutions,
131                        link_range,
132                        dest_url.to_string(),
133                        link_data,
134                        if link_type == LinkType::Inline { (b'(', b')') } else { (b'[', b']') },
135                    )
136                }
137                LinkType::Reference => check_reference_redundancy(
138                    cx,
139                    item,
140                    module_id,
141                    hir_id,
142                    doc,
143                    resolutions,
144                    link_range,
145                    &dest_url,
146                    link_data,
147                ),
148                _ => Ok(()),
149            };
150            if let Err(lint) = check_result {
151                cx.tcx.emit_node_span_lint(
152                    crate::lint::REDUNDANT_EXPLICIT_LINKS,
153                    hir_id,
154                    item.attr_span(cx.tcx),
155                    lint,
156                );
157            }
158        }
159    }
160}
161
162struct RedundantExplicitLinksWithoutSuggestion {
163    attr_span: Span,
164    display_link: String,
165    dest_link: String,
166}
167
168impl<'a> Diagnostic<'a> for RedundantExplicitLinksWithoutSuggestion {
169    fn into_diag(self, dcx: DiagCtxtHandle<'a>, level: Level) -> Diag<'a> {
170        let Self { attr_span, display_link, dest_link } = self;
171
172        Diag::new(dcx, level, "redundant explicit link target")
173            .with_span_label(
174                attr_span,
175                format!("explicit target `{dest_link}` is redundant because label `{display_link}` resolves to same destination")
176            )
177            .with_note(
178                "when a link's destination is not specified,\nthe label is used to resolve intra-doc links"
179            )
180    }
181}
182
183/// FIXME(ChAoSUnItY): Too many arguments.
184fn check_inline_or_reference_unknown_redundancy(
185    cx: &DocContext<'_>,
186    item: &Item,
187    module_id: DefId,
188    hir_id: HirId,
189    doc: &str,
190    resolutions: &DocLinkResMap,
191    link_range: Range<usize>,
192    dest: String,
193    link_data: LinkData,
194    (open, close): (u8, u8),
195) -> Result<(), RedundantExplicitLinksWithoutSuggestion> {
196    struct RedundantExplicitLinks {
197        explicit_span: Span,
198        display_span: Span,
199        link_span: Span,
200        display_link: String,
201    }
202
203    impl<'a> Diagnostic<'a> for RedundantExplicitLinks {
204        fn into_diag(self, dcx: DiagCtxtHandle<'a>, level: Level) -> Diag<'a> {
205            let Self { explicit_span, display_span, link_span, display_link } = self;
206
207            Diag::new(dcx, level, "redundant explicit link target")
208                .with_span_label(
209                    explicit_span,
210                    "explicit target is redundant",
211                )
212                .with_span_label(
213                    display_span,
214                    "because label contains path that resolves to same destination",
215                )
216                .with_note(
217                    "when a link's destination is not specified,\nthe label is used to resolve intra-doc links"
218                )
219                // FIXME (GuillaumeGomez): We cannot use `derive(Diagnostic)` because of this method.
220                .with_span_suggestion_with_style(
221                    link_span,
222                    "remove explicit link target",
223                    format!("[{}]", display_link),
224                    Applicability::MaybeIncorrect,
225                    SuggestionStyle::ShowAlways,
226                )
227        }
228    }
229
230    let (Some(resolvable_link), Some(resolvable_link_range)) =
231        (&link_data.resolvable_link, &link_data.resolvable_link_range)
232    else {
233        return Ok(());
234    };
235
236    if explicit_link_is_redundant(cx, module_id, resolutions, &dest, resolvable_link) {
237        let attr_span = item.attr_span(cx.tcx);
238        let link_span =
239            match source_span_for_markdown_range(cx.tcx, doc, &link_range, &item.attrs.doc_strings)
240            {
241                Some((sp, from_expansion)) => {
242                    if from_expansion {
243                        return Ok(());
244                    }
245                    sp
246                }
247                None => attr_span,
248            };
249        let explicit_span = match source_span_for_markdown_range(
250            cx.tcx,
251            doc,
252            &offset_explicit_range(doc, link_range, open, close),
253            &item.attrs.doc_strings,
254        ) {
255            Some((explicit_span, false)) => explicit_span,
256            // This `span` comes from macro expansion so skipping it.
257            Some((_, true)) => return Ok(()),
258            // Cannot give a contiguous span for this link.
259            None => {
260                return Err(RedundantExplicitLinksWithoutSuggestion {
261                    display_link: resolvable_link.clone(),
262                    dest_link: dest.to_string(),
263                    attr_span,
264                });
265            }
266        };
267        let display_span = match source_span_for_markdown_range(
268            cx.tcx,
269            doc,
270            resolvable_link_range,
271            &item.attrs.doc_strings,
272        ) {
273            Some((display_span, false)) => display_span,
274            // This `span` comes from macro expansion so skipping it.
275            Some((_, true)) => return Ok(()),
276            // Cannot give a contiguous span for this link.
277            None => {
278                return Err(RedundantExplicitLinksWithoutSuggestion {
279                    display_link: resolvable_link.clone(),
280                    dest_link: dest.to_string(),
281                    attr_span,
282                });
283            }
284        };
285
286        cx.tcx.emit_node_span_lint(
287            crate::lint::REDUNDANT_EXPLICIT_LINKS,
288            hir_id,
289            explicit_span,
290            RedundantExplicitLinks {
291                explicit_span,
292                display_span,
293                link_span,
294                display_link: link_data.display_link,
295            },
296        );
297    }
298
299    Ok(())
300}
301
302/// FIXME(ChAoSUnItY): Too many arguments.
303fn check_reference_redundancy(
304    cx: &DocContext<'_>,
305    item: &Item,
306    module_id: DefId,
307    hir_id: HirId,
308    doc: &str,
309    resolutions: &DocLinkResMap,
310    link_range: Range<usize>,
311    dest: &CowStr<'_>,
312    link_data: LinkData,
313) -> Result<(), RedundantExplicitLinksWithoutSuggestion> {
314    struct RedundantExplicitLinkTarget {
315        explicit_span: Span,
316        display_span: Span,
317        def_span: Span,
318        link_span: Span,
319        display_link: String,
320    }
321
322    impl<'a> Diagnostic<'a> for RedundantExplicitLinkTarget {
323        fn into_diag(self, dcx: DiagCtxtHandle<'a>, level: Level) -> Diag<'a> {
324            let Self { explicit_span, display_span, def_span, link_span, display_link } = self;
325
326            Diag::new(dcx, level, "redundant explicit link target")
327                .with_span_label(explicit_span, "explicit target is redundant")
328                .with_span_label(
329                    display_span,
330                    "because label contains path that resolves to same destination",
331                )
332                .with_span_note(def_span, "referenced explicit link target defined here")
333                .with_note(
334                    "when a link's destination is not specified,\nthe label is used to resolve intra-doc links"
335                )
336                // FIXME (GuillaumeGomez): We cannot use `derive(Diagnostic)` because of this method.
337                .with_span_suggestion_with_style(
338                    link_span,
339                    "remove explicit link target",
340                    format!("[{}]", display_link),
341                    Applicability::MaybeIncorrect,
342                    SuggestionStyle::ShowAlways,
343                )
344        }
345    }
346
347    let (Some(resolvable_link), Some(resolvable_link_range)) =
348        (&link_data.resolvable_link, &link_data.resolvable_link_range)
349    else {
350        return Ok(());
351    };
352
353    if explicit_link_is_redundant(cx, module_id, resolutions, dest, resolvable_link) {
354        let attr_span = item.attr_span(cx.tcx);
355        let link_span =
356            match source_span_for_markdown_range(cx.tcx, doc, &link_range, &item.attrs.doc_strings)
357            {
358                Some((sp, from_expansion)) => {
359                    if from_expansion {
360                        // This `span` comes from macro expansion so skipping it.
361                        return Ok(());
362                    }
363                    sp
364                }
365                None => attr_span,
366            };
367        let explicit_span = match source_span_for_markdown_range(
368            cx.tcx,
369            doc,
370            &offset_explicit_range(doc, link_range.clone(), b'[', b']'),
371            &item.attrs.doc_strings,
372        ) {
373            Some((explicit_span, false)) => explicit_span,
374            // This `span` comes from macro expansion so skipping it.
375            Some((_, true)) => return Ok(()),
376            // Cannot give a contiguous span for this link.
377            None => {
378                return Err(RedundantExplicitLinksWithoutSuggestion {
379                    display_link: resolvable_link.clone(),
380                    dest_link: dest.to_string(),
381                    attr_span,
382                });
383            }
384        };
385        let display_span = match source_span_for_markdown_range(
386            cx.tcx,
387            doc,
388            resolvable_link_range,
389            &item.attrs.doc_strings,
390        ) {
391            Some((display_span, false)) => display_span,
392            // This `span` comes from macro expansion so skipping it.
393            Some((_, true)) => return Ok(()),
394            // Cannot give a contiguous span for this link.
395            None => {
396                return Err(RedundantExplicitLinksWithoutSuggestion {
397                    display_link: resolvable_link.clone(),
398                    dest_link: dest.to_string(),
399                    attr_span,
400                });
401            }
402        };
403        let def_span = match source_span_for_markdown_range(
404            cx.tcx,
405            doc,
406            &offset_reference_def_range(doc, dest, link_range),
407            &item.attrs.doc_strings,
408        ) {
409            Some((def_span, _)) => def_span,
410            // Cannot give a contiguous span for this link.
411            None => {
412                return Err(RedundantExplicitLinksWithoutSuggestion {
413                    display_link: resolvable_link.clone(),
414                    dest_link: dest.to_string(),
415                    attr_span,
416                });
417            }
418        };
419
420        cx.tcx.emit_node_span_lint(
421            crate::lint::REDUNDANT_EXPLICIT_LINKS,
422            hir_id,
423            explicit_span,
424            RedundantExplicitLinkTarget {
425                explicit_span,
426                display_span,
427                def_span,
428                link_span,
429                display_link: link_data.display_link,
430            },
431        );
432    }
433
434    Ok(())
435}
436
437fn explicit_link_is_redundant(
438    cx: &DocContext<'_>,
439    module_id: DefId,
440    resolutions: &DocLinkResMap,
441    dest: &str,
442    resolvable_link: &str,
443) -> bool {
444    let Some(display_res) = find_resolution(resolutions, resolvable_link) else {
445        return false;
446    };
447
448    if (dest.ends_with(resolvable_link) || resolvable_link.ends_with(dest))
449        && find_resolution(resolutions, dest).is_some_and(|dest_res| dest_res == display_res)
450    {
451        return true;
452    }
453
454    if dest.contains('#') || !dest.ends_with(".html") {
455        return false;
456    }
457
458    local_href_for_res(cx, module_id, display_res).is_some_and(|href| href == dest)
459}
460
461fn local_href_for_res(cx: &DocContext<'_>, module_id: DefId, res: Res<NodeId>) -> Option<String> {
462    let mut did = res.opt_def_id()?;
463    if matches!(cx.tcx.def_kind(did), DefKind::Ctor(..)) {
464        did = cx.tcx.parent(did);
465    }
466
467    if matches!(
468        cx.tcx.def_kind(did),
469        DefKind::AssocTy | DefKind::AssocFn | DefKind::AssocConst | DefKind::Variant
470    ) || !did.is_local()
471    {
472        return None;
473    }
474
475    let item_type = ItemType::from_def_id(did, cx.tcx);
476    let fqp = inline::get_item_path(cx.tcx, did, item_type);
477    let module_fqp = if item_type == ItemType::Module { &fqp[..] } else { &fqp[..fqp.len() - 1] };
478    let current_fqp = inline::get_item_path(cx.tcx, module_id, ItemType::Module);
479
480    let mut url_parts = href_relative_parts(module_fqp, &current_fqp);
481    match item_type {
482        ItemType::Module => url_parts.push("index.html"),
483        _ => url_parts.push_fmt(format_args!(
484            "{}.{last}.html",
485            item_type.as_str(),
486            last = fqp.last()?
487        )),
488    }
489    Some(url_parts.finish())
490}
491
492fn find_resolution(resolutions: &DocLinkResMap, path: &str) -> Option<Res<NodeId>> {
493    [Namespace::TypeNS, Namespace::ValueNS, Namespace::MacroNS]
494        .into_iter()
495        .find_map(|ns| resolutions.get(&(Symbol::intern(path), ns)).copied().flatten())
496}
497
498/// Collects all necessary data of link.
499fn collect_link_data<'input, F: BrokenLinkCallback<'input>>(
500    offset_iter: &mut OffsetIter<'input, F>,
501) -> LinkData {
502    let mut resolvable_link = None;
503    let mut resolvable_link_range = None;
504    let mut display_link = String::new();
505    let mut is_resolvable = true;
506
507    for (event, range) in offset_iter.by_ref() {
508        match event {
509            Event::Text(code) => {
510                let code = code.to_string();
511                display_link.push_str(&code);
512                resolvable_link = Some(code);
513                resolvable_link_range = Some(range);
514            }
515            Event::Code(code) => {
516                let code = code.to_string();
517                display_link.push('`');
518                display_link.push_str(&code);
519                display_link.push('`');
520                resolvable_link = Some(code);
521                resolvable_link_range = Some(range);
522            }
523            Event::Start(_) => {
524                // If there is anything besides backticks, it's not considered as an intra-doc link
525                // so we ignore it.
526                is_resolvable = false;
527            }
528            Event::End(_) => {
529                break;
530            }
531            _ => {}
532        }
533    }
534
535    if !is_resolvable {
536        resolvable_link_range = None;
537        resolvable_link = None;
538    }
539
540    LinkData { resolvable_link, resolvable_link_range, display_link }
541}
542
543fn offset_explicit_range(md: &str, link_range: Range<usize>, open: u8, close: u8) -> Range<usize> {
544    let mut open_brace = !0;
545    let mut close_brace = !0;
546    for (i, b) in md.as_bytes()[link_range.clone()].iter().copied().enumerate().rev() {
547        let i = i + link_range.start;
548        if b == close {
549            close_brace = i;
550            break;
551        }
552    }
553
554    if close_brace < link_range.start || close_brace >= link_range.end {
555        return link_range;
556    }
557
558    let mut nesting = 1;
559
560    for (i, b) in md.as_bytes()[link_range.start..close_brace].iter().copied().enumerate().rev() {
561        let i = i + link_range.start;
562        if b == close {
563            nesting += 1;
564        }
565        if b == open {
566            nesting -= 1;
567        }
568        if nesting == 0 {
569            open_brace = i;
570            break;
571        }
572    }
573
574    assert!(open_brace != close_brace);
575
576    if open_brace < link_range.start || open_brace >= link_range.end {
577        return link_range;
578    }
579    // do not actually include braces in the span
580    (open_brace + 1)..close_brace
581}
582
583fn offset_reference_def_range(
584    md: &str,
585    dest: &CowStr<'_>,
586    link_range: Range<usize>,
587) -> Range<usize> {
588    // For diagnostics, we want to underline the link's definition but `span` will point at
589    // where the link is used. This is a problem for reference-style links, where the definition
590    // is separate from the usage.
591
592    match dest {
593        // `Borrowed` variant means the string (the link's destination) may come directly from
594        // the markdown text and we can locate the original link destination.
595        // NOTE: LinkReplacer also provides `Borrowed` but possibly from other sources,
596        // so `locate()` can fall back to use `span`.
597        CowStr::Borrowed(s) => {
598            // FIXME: remove this function once pulldown_cmark can provide spans for link definitions.
599            unsafe {
600                let s_start = dest.as_ptr();
601                let s_end = s_start.add(s.len());
602                let md_start = md.as_ptr();
603                let md_end = md_start.add(md.len());
604                if md_start <= s_start && s_end <= md_end {
605                    let start = s_start.offset_from(md_start) as usize;
606                    let end = s_end.offset_from(md_start) as usize;
607                    start..end
608                } else {
609                    link_range
610                }
611            }
612        }
613
614        // For anything else, we can only use the provided range.
615        CowStr::Boxed(_) | CowStr::Inlined(_) => link_range,
616    }
617}