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