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, DocLinkResMap, Namespace, Res};
7use rustc_lint_defs::Applicability;
8use rustc_resolve::rustdoc::pulldown_cmark::{
9    BrokenLink, BrokenLinkCallback, CowStr, Event, LinkType, OffsetIter, Parser, Tag,
10};
11use rustc_resolve::rustdoc::{prepare_to_doc_link_resolution, source_span_for_markdown_range};
12use rustc_span::def_id::{DefId, ModId};
13use rustc_span::{Span, Symbol};
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.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 =
55        !cx.document_private() && !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) => ModId::new_unchecked(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) {
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, title, .. }) = event {
94            if !title.is_empty() {
95                // Skips if the link specifies a title, e.g. `[Option](Option "title")`,
96                // in which case the explicit link cannot be removed without also
97                // removing the title.
98                continue;
99            }
100
101            let link_data = collect_link_data(&mut offset_iter);
102
103            let Some(resolvable_link) = link_data.resolvable_link.as_ref() else {
104                // collect_link_data didn't return a resolvable_link
105                // most likely due to the displayed link containing inline markup
106                continue;
107            };
108
109            if &link_data.display_link.replace('`', "") != resolvable_link {
110                // Skips if display link does not match to actual
111                // resolvable link, usually happens if display link
112                // has several segments, e.g.
113                // [this is just an `Option`](Option)
114                continue;
115            }
116
117            if dest_url.ends_with(resolvable_link) || resolvable_link.ends_with(&*dest_url) {
118                let check_result = match link_type {
119                    LinkType::Inline | LinkType::ReferenceUnknown => {
120                        check_inline_or_reference_unknown_redundancy(
121                            cx,
122                            item,
123                            hir_id,
124                            doc,
125                            resolutions,
126                            link_range,
127                            dest_url.to_string(),
128                            link_data,
129                            if link_type == LinkType::Inline { (b'(', b')') } else { (b'[', b']') },
130                        )
131                    }
132                    LinkType::Reference => check_reference_redundancy(
133                        cx,
134                        item,
135                        hir_id,
136                        doc,
137                        resolutions,
138                        link_range,
139                        &dest_url,
140                        link_data,
141                    ),
142                    _ => Ok(()),
143                };
144                if let Err(lint) = check_result {
145                    cx.tcx.emit_node_span_lint(
146                        crate::lint::REDUNDANT_EXPLICIT_LINKS,
147                        hir_id,
148                        item.attr_span(cx.tcx),
149                        lint,
150                    );
151                }
152            }
153        }
154    }
155}
156
157struct RedundantExplicitLinksWithoutSuggestion {
158    attr_span: Span,
159    display_link: String,
160    dest_link: String,
161}
162
163impl<'a> Diagnostic<'a, ()> for RedundantExplicitLinksWithoutSuggestion {
164    fn into_diag(self, dcx: DiagCtxtHandle<'a>, level: Level) -> Diag<'a, ()> {
165        let Self { attr_span, display_link, dest_link } = self;
166
167        Diag::new(dcx, level, "redundant explicit link target")
168            .with_span_label(
169                attr_span,
170                format!("explicit target `{dest_link}` is redundant because label `{display_link}` resolves to same destination")
171            )
172            .with_note(
173                "when a link's destination is not specified,\nthe label is used to resolve intra-doc links"
174            )
175    }
176}
177
178/// FIXME(ChAoSUnItY): Too many arguments.
179fn check_inline_or_reference_unknown_redundancy(
180    cx: &DocContext<'_>,
181    item: &Item,
182    hir_id: HirId,
183    doc: &str,
184    resolutions: &DocLinkResMap,
185    link_range: Range<usize>,
186    dest: String,
187    link_data: LinkData,
188    (open, close): (u8, u8),
189) -> Result<(), RedundantExplicitLinksWithoutSuggestion> {
190    struct RedundantExplicitLinks {
191        explicit_span: Span,
192        display_span: Span,
193        link_span: Span,
194        display_link: String,
195    }
196
197    impl<'a> Diagnostic<'a, ()> for RedundantExplicitLinks {
198        fn into_diag(self, dcx: DiagCtxtHandle<'a>, level: Level) -> Diag<'a, ()> {
199            let Self { explicit_span, display_span, link_span, display_link } = self;
200
201            Diag::new(dcx, level, "redundant explicit link target")
202                .with_span_label(
203                    explicit_span,
204                    "explicit target is redundant",
205                )
206                .with_span_label(
207                    display_span,
208                    "because label contains path that resolves to same destination",
209                )
210                .with_note(
211                    "when a link's destination is not specified,\nthe label is used to resolve intra-doc links"
212                )
213                // FIXME (GuillaumeGomez): We cannot use `derive(Diagnostic)` because of this method.
214                .with_span_suggestion_with_style(
215                    link_span,
216                    "remove explicit link target",
217                    format!("[{}]", display_link),
218                    Applicability::MaybeIncorrect,
219                    SuggestionStyle::ShowAlways,
220                )
221        }
222    }
223
224    let (Some(resolvable_link), Some(resolvable_link_range)) =
225        (&link_data.resolvable_link, &link_data.resolvable_link_range)
226    else {
227        return Ok(());
228    };
229    let (Some(dest_res), Some(display_res)) =
230        (find_resolution(resolutions, &dest), find_resolution(resolutions, resolvable_link))
231    else {
232        return Ok(());
233    };
234
235    if dest_res == display_res {
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    hir_id: HirId,
306    doc: &str,
307    resolutions: &DocLinkResMap,
308    link_range: Range<usize>,
309    dest: &CowStr<'_>,
310    link_data: LinkData,
311) -> Result<(), RedundantExplicitLinksWithoutSuggestion> {
312    struct RedundantExplicitLinkTarget {
313        explicit_span: Span,
314        display_span: Span,
315        def_span: Span,
316        link_span: Span,
317        display_link: String,
318    }
319
320    impl<'a> Diagnostic<'a, ()> for RedundantExplicitLinkTarget {
321        fn into_diag(self, dcx: DiagCtxtHandle<'a>, level: Level) -> Diag<'a, ()> {
322            let Self { explicit_span, display_span, def_span, link_span, display_link } = self;
323
324            Diag::new(dcx, level, "redundant explicit link target")
325                .with_span_label(explicit_span, "explicit target is redundant")
326                .with_span_label(
327                    display_span,
328                    "because label contains path that resolves to same destination",
329                )
330                .with_span_note(def_span, "referenced explicit link target defined here")
331                .with_note(
332                    "when a link's destination is not specified,\nthe label is used to resolve intra-doc links"
333                )
334                // FIXME (GuillaumeGomez): We cannot use `derive(Diagnostic)` because of this method.
335                .with_span_suggestion_with_style(
336                    link_span,
337                    "remove explicit link target",
338                    format!("[{}]", display_link),
339                    Applicability::MaybeIncorrect,
340                    SuggestionStyle::ShowAlways,
341                )
342        }
343    }
344
345    let (Some(resolvable_link), Some(resolvable_link_range)) =
346        (&link_data.resolvable_link, &link_data.resolvable_link_range)
347    else {
348        return Ok(());
349    };
350    let (Some(dest_res), Some(display_res)) =
351        (find_resolution(resolutions, dest), find_resolution(resolutions, resolvable_link))
352    else {
353        return Ok(());
354    };
355
356    if dest_res == display_res {
357        let attr_span = item.attr_span(cx.tcx);
358        let link_span =
359            match source_span_for_markdown_range(cx.tcx, doc, &link_range, &item.attrs.doc_strings)
360            {
361                Some((sp, from_expansion)) => {
362                    if from_expansion {
363                        // This `span` comes from macro expansion so skipping it.
364                        return Ok(());
365                    }
366                    sp
367                }
368                None => attr_span,
369            };
370        let explicit_span = match source_span_for_markdown_range(
371            cx.tcx,
372            doc,
373            &offset_explicit_range(doc, link_range.clone(), b'[', b']'),
374            &item.attrs.doc_strings,
375        ) {
376            Some((explicit_span, false)) => explicit_span,
377            // This `span` comes from macro expansion so skipping it.
378            Some((_, true)) => return Ok(()),
379            // Cannot give a contiguous span for this link.
380            None => {
381                return Err(RedundantExplicitLinksWithoutSuggestion {
382                    display_link: resolvable_link.clone(),
383                    dest_link: dest.to_string(),
384                    attr_span,
385                });
386            }
387        };
388        let display_span = match source_span_for_markdown_range(
389            cx.tcx,
390            doc,
391            resolvable_link_range,
392            &item.attrs.doc_strings,
393        ) {
394            Some((display_span, false)) => display_span,
395            // This `span` comes from macro expansion so skipping it.
396            Some((_, true)) => return Ok(()),
397            // Cannot give a contiguous span for this link.
398            None => {
399                return Err(RedundantExplicitLinksWithoutSuggestion {
400                    display_link: resolvable_link.clone(),
401                    dest_link: dest.to_string(),
402                    attr_span,
403                });
404            }
405        };
406        let def_span = match source_span_for_markdown_range(
407            cx.tcx,
408            doc,
409            &offset_reference_def_range(doc, dest, link_range),
410            &item.attrs.doc_strings,
411        ) {
412            Some((def_span, _)) => def_span,
413            // Cannot give a contiguous span for this link.
414            None => {
415                return Err(RedundantExplicitLinksWithoutSuggestion {
416                    display_link: resolvable_link.clone(),
417                    dest_link: dest.to_string(),
418                    attr_span,
419                });
420            }
421        };
422
423        cx.tcx.emit_node_span_lint(
424            crate::lint::REDUNDANT_EXPLICIT_LINKS,
425            hir_id,
426            explicit_span,
427            RedundantExplicitLinkTarget {
428                explicit_span,
429                display_span,
430                def_span,
431                link_span,
432                display_link: link_data.display_link,
433            },
434        );
435    }
436
437    Ok(())
438}
439
440fn find_resolution(resolutions: &DocLinkResMap, path: &str) -> Option<Res<NodeId>> {
441    [Namespace::TypeNS, Namespace::ValueNS, Namespace::MacroNS]
442        .into_iter()
443        .find_map(|ns| resolutions.get(&(Symbol::intern(path), ns)).copied().flatten())
444}
445
446/// Collects all necessary data of link.
447fn collect_link_data<'input, F: BrokenLinkCallback<'input>>(
448    offset_iter: &mut OffsetIter<'input, F>,
449) -> LinkData {
450    let mut resolvable_link = None;
451    let mut resolvable_link_range = None;
452    let mut display_link = String::new();
453    let mut is_resolvable = true;
454
455    for (event, range) in offset_iter.by_ref() {
456        match event {
457            Event::Text(code) => {
458                let code = code.to_string();
459                display_link.push_str(&code);
460                resolvable_link = Some(code);
461                resolvable_link_range = Some(range);
462            }
463            Event::Code(code) => {
464                let code = code.to_string();
465                display_link.push('`');
466                display_link.push_str(&code);
467                display_link.push('`');
468                resolvable_link = Some(code);
469                resolvable_link_range = Some(range);
470            }
471            Event::Start(_) => {
472                // If there is anything besides backticks, it's not considered as an intra-doc link
473                // so we ignore it.
474                is_resolvable = false;
475            }
476            Event::End(_) => {
477                break;
478            }
479            _ => {}
480        }
481    }
482
483    if !is_resolvable {
484        resolvable_link_range = None;
485        resolvable_link = None;
486    }
487
488    LinkData { resolvable_link, resolvable_link_range, display_link }
489}
490
491fn offset_explicit_range(md: &str, link_range: Range<usize>, open: u8, close: u8) -> Range<usize> {
492    let mut open_brace = !0;
493    let mut close_brace = !0;
494    for (i, b) in md.as_bytes()[link_range.clone()].iter().copied().enumerate().rev() {
495        let i = i + link_range.start;
496        if b == close {
497            close_brace = i;
498            break;
499        }
500    }
501
502    if close_brace < link_range.start || close_brace >= link_range.end {
503        return link_range;
504    }
505
506    let mut nesting = 1;
507
508    for (i, b) in md.as_bytes()[link_range.start..close_brace].iter().copied().enumerate().rev() {
509        let i = i + link_range.start;
510        if b == close {
511            nesting += 1;
512        }
513        if b == open {
514            nesting -= 1;
515        }
516        if nesting == 0 {
517            open_brace = i;
518            break;
519        }
520    }
521
522    assert!(open_brace != close_brace);
523
524    if open_brace < link_range.start || open_brace >= link_range.end {
525        return link_range;
526    }
527    // do not actually include braces in the span
528    (open_brace + 1)..close_brace
529}
530
531fn offset_reference_def_range(
532    md: &str,
533    dest: &CowStr<'_>,
534    link_range: Range<usize>,
535) -> Range<usize> {
536    // For diagnostics, we want to underline the link's definition but `span` will point at
537    // where the link is used. This is a problem for reference-style links, where the definition
538    // is separate from the usage.
539
540    match dest {
541        // `Borrowed` variant means the string (the link's destination) may come directly from
542        // the markdown text and we can locate the original link destination.
543        // NOTE: LinkReplacer also provides `Borrowed` but possibly from other sources,
544        // so `locate()` can fall back to use `span`.
545        CowStr::Borrowed(s) => {
546            // FIXME: remove this function once pulldown_cmark can provide spans for link definitions.
547            unsafe {
548                let s_start = dest.as_ptr();
549                let s_end = s_start.add(s.len());
550                let md_start = md.as_ptr();
551                let md_end = md_start.add(md.len());
552                if md_start <= s_start && s_end <= md_end {
553                    let start = s_start.offset_from(md_start) as usize;
554                    let end = s_end.offset_from(md_start) as usize;
555                    start..end
556                } else {
557                    link_range
558                }
559            }
560        }
561
562        // For anything else, we can only use the provided range.
563        CowStr::Boxed(_) | CowStr::Inlined(_) => link_range,
564    }
565}