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;
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) => 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, 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                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 => {
133                        check_reference_redundancy(
134                            cx,
135                            item,
136                            hir_id,
137                            doc,
138                            resolutions,
139                            link_range,
140                            &dest_url,
141                            link_data,
142                        );
143                    }
144                    _ => {}
145                }
146            }
147        }
148    }
149
150    None
151}
152
153/// FIXME(ChAoSUnItY): Too many arguments.
154fn check_inline_or_reference_unknown_redundancy(
155    cx: &DocContext<'_>,
156    item: &Item,
157    hir_id: HirId,
158    doc: &str,
159    resolutions: &DocLinkResMap,
160    link_range: Range<usize>,
161    dest: String,
162    link_data: LinkData,
163    (open, close): (u8, u8),
164) -> Option<()> {
165    struct RedundantExplicitLinks {
166        explicit_span: Span,
167        display_span: Span,
168        link_span: Span,
169        display_link: String,
170    }
171
172    impl<'a> Diagnostic<'a, ()> for RedundantExplicitLinks {
173        fn into_diag(self, dcx: DiagCtxtHandle<'a>, level: Level) -> Diag<'a, ()> {
174            let Self { explicit_span, display_span, link_span, display_link } = self;
175
176            Diag::new(dcx, level, "redundant explicit link target")
177                .with_span_label(
178                    explicit_span,
179                    "explicit target is redundant",
180                )
181                .with_span_label(
182                    display_span,
183                    "because label contains path that resolves to same destination",
184                )
185                .with_note(
186                    "when a link's destination is not specified,\nthe label is used to resolve intra-doc links"
187                )
188                // FIXME (GuillaumeGomez): We cannot use `derive(Diagnostic)` because of this method.
189                .with_span_suggestion_with_style(
190                    link_span,
191                    "remove explicit link target",
192                    format!("[{}]", display_link),
193                    Applicability::MaybeIncorrect,
194                    SuggestionStyle::ShowAlways,
195                )
196        }
197    }
198
199    let (resolvable_link, resolvable_link_range) =
200        (&link_data.resolvable_link?, &link_data.resolvable_link_range?);
201    let (dest_res, display_res) =
202        (find_resolution(resolutions, &dest)?, find_resolution(resolutions, resolvable_link)?);
203
204    if dest_res == display_res {
205        let link_span =
206            match source_span_for_markdown_range(cx.tcx, doc, &link_range, &item.attrs.doc_strings)
207            {
208                Some((sp, from_expansion)) => {
209                    if from_expansion {
210                        return None;
211                    }
212                    sp
213                }
214                None => item.attr_span(cx.tcx),
215            };
216        let (explicit_span, false) = source_span_for_markdown_range(
217            cx.tcx,
218            doc,
219            &offset_explicit_range(doc, link_range, open, close),
220            &item.attrs.doc_strings,
221        )?
222        else {
223            // This `span` comes from macro expansion so skipping it.
224            return None;
225        };
226        let (display_span, false) = source_span_for_markdown_range(
227            cx.tcx,
228            doc,
229            resolvable_link_range,
230            &item.attrs.doc_strings,
231        )?
232        else {
233            // This `span` comes from macro expansion so skipping it.
234            return None;
235        };
236
237        cx.tcx.emit_node_span_lint(
238            crate::lint::REDUNDANT_EXPLICIT_LINKS,
239            hir_id,
240            explicit_span,
241            RedundantExplicitLinks {
242                explicit_span,
243                display_span,
244                link_span,
245                display_link: link_data.display_link,
246            },
247        );
248    }
249
250    None
251}
252
253/// FIXME(ChAoSUnItY): Too many arguments.
254fn check_reference_redundancy(
255    cx: &DocContext<'_>,
256    item: &Item,
257    hir_id: HirId,
258    doc: &str,
259    resolutions: &DocLinkResMap,
260    link_range: Range<usize>,
261    dest: &CowStr<'_>,
262    link_data: LinkData,
263) -> Option<()> {
264    struct RedundantExplicitLinkTarget {
265        explicit_span: Span,
266        display_span: Span,
267        def_span: Span,
268        link_span: Span,
269        display_link: String,
270    }
271
272    impl<'a> Diagnostic<'a, ()> for RedundantExplicitLinkTarget {
273        fn into_diag(self, dcx: DiagCtxtHandle<'a>, level: Level) -> Diag<'a, ()> {
274            let Self { explicit_span, display_span, def_span, link_span, display_link } = self;
275
276            Diag::new(dcx, level, "redundant explicit link target")
277                .with_span_label(explicit_span, "explicit target is redundant")
278                .with_span_label(
279                    display_span,
280                    "because label contains path that resolves to same destination",
281                )
282                .with_span_note(def_span, "referenced explicit link target defined here")
283                .with_note(
284                    "when a link's destination is not specified,\nthe label is used to resolve intra-doc links"
285                )
286                // FIXME (GuillaumeGomez): We cannot use `derive(Diagnostic)` because of this method.
287                .with_span_suggestion_with_style(
288                    link_span,
289                    "remove explicit link target",
290                    format!("[{}]", display_link),
291                    Applicability::MaybeIncorrect,
292                    SuggestionStyle::ShowAlways,
293                )
294        }
295    }
296
297    let (resolvable_link, resolvable_link_range) =
298        (&link_data.resolvable_link?, &link_data.resolvable_link_range?);
299    let (dest_res, display_res) =
300        (find_resolution(resolutions, dest)?, find_resolution(resolutions, resolvable_link)?);
301
302    if dest_res == display_res {
303        let link_span =
304            match source_span_for_markdown_range(cx.tcx, doc, &link_range, &item.attrs.doc_strings)
305            {
306                Some((sp, from_expansion)) => {
307                    if from_expansion {
308                        return None;
309                    }
310                    sp
311                }
312                None => item.attr_span(cx.tcx),
313            };
314        let (explicit_span, false) = source_span_for_markdown_range(
315            cx.tcx,
316            doc,
317            &offset_explicit_range(doc, link_range.clone(), b'[', b']'),
318            &item.attrs.doc_strings,
319        )?
320        else {
321            // This `span` comes from macro expansion so skipping it.
322            return None;
323        };
324        let (display_span, false) = source_span_for_markdown_range(
325            cx.tcx,
326            doc,
327            resolvable_link_range,
328            &item.attrs.doc_strings,
329        )?
330        else {
331            // This `span` comes from macro expansion so skipping it.
332            return None;
333        };
334        let (def_span, _) = source_span_for_markdown_range(
335            cx.tcx,
336            doc,
337            &offset_reference_def_range(doc, dest, link_range),
338            &item.attrs.doc_strings,
339        )?;
340
341        cx.tcx.emit_node_span_lint(
342            crate::lint::REDUNDANT_EXPLICIT_LINKS,
343            hir_id,
344            explicit_span,
345            RedundantExplicitLinkTarget {
346                explicit_span,
347                display_span,
348                def_span,
349                link_span,
350                display_link: link_data.display_link,
351            },
352        );
353    }
354
355    None
356}
357
358fn find_resolution(resolutions: &DocLinkResMap, path: &str) -> Option<Res<NodeId>> {
359    [Namespace::TypeNS, Namespace::ValueNS, Namespace::MacroNS]
360        .into_iter()
361        .find_map(|ns| resolutions.get(&(Symbol::intern(path), ns)).copied().flatten())
362}
363
364/// Collects all necessary data of link.
365fn collect_link_data<'input, F: BrokenLinkCallback<'input>>(
366    offset_iter: &mut OffsetIter<'input, F>,
367) -> LinkData {
368    let mut resolvable_link = None;
369    let mut resolvable_link_range = None;
370    let mut display_link = String::new();
371    let mut is_resolvable = true;
372
373    for (event, range) in offset_iter.by_ref() {
374        match event {
375            Event::Text(code) => {
376                let code = code.to_string();
377                display_link.push_str(&code);
378                resolvable_link = Some(code);
379                resolvable_link_range = Some(range);
380            }
381            Event::Code(code) => {
382                let code = code.to_string();
383                display_link.push('`');
384                display_link.push_str(&code);
385                display_link.push('`');
386                resolvable_link = Some(code);
387                resolvable_link_range = Some(range);
388            }
389            Event::Start(_) => {
390                // If there is anything besides backticks, it's not considered as an intra-doc link
391                // so we ignore it.
392                is_resolvable = false;
393            }
394            Event::End(_) => {
395                break;
396            }
397            _ => {}
398        }
399    }
400
401    if !is_resolvable {
402        resolvable_link_range = None;
403        resolvable_link = None;
404    }
405
406    LinkData { resolvable_link, resolvable_link_range, display_link }
407}
408
409fn offset_explicit_range(md: &str, link_range: Range<usize>, open: u8, close: u8) -> Range<usize> {
410    let mut open_brace = !0;
411    let mut close_brace = !0;
412    for (i, b) in md.as_bytes()[link_range.clone()].iter().copied().enumerate().rev() {
413        let i = i + link_range.start;
414        if b == close {
415            close_brace = i;
416            break;
417        }
418    }
419
420    if close_brace < link_range.start || close_brace >= link_range.end {
421        return link_range;
422    }
423
424    let mut nesting = 1;
425
426    for (i, b) in md.as_bytes()[link_range.start..close_brace].iter().copied().enumerate().rev() {
427        let i = i + link_range.start;
428        if b == close {
429            nesting += 1;
430        }
431        if b == open {
432            nesting -= 1;
433        }
434        if nesting == 0 {
435            open_brace = i;
436            break;
437        }
438    }
439
440    assert!(open_brace != close_brace);
441
442    if open_brace < link_range.start || open_brace >= link_range.end {
443        return link_range;
444    }
445    // do not actually include braces in the span
446    (open_brace + 1)..close_brace
447}
448
449fn offset_reference_def_range(
450    md: &str,
451    dest: &CowStr<'_>,
452    link_range: Range<usize>,
453) -> Range<usize> {
454    // For diagnostics, we want to underline the link's definition but `span` will point at
455    // where the link is used. This is a problem for reference-style links, where the definition
456    // is separate from the usage.
457
458    match dest {
459        // `Borrowed` variant means the string (the link's destination) may come directly from
460        // the markdown text and we can locate the original link destination.
461        // NOTE: LinkReplacer also provides `Borrowed` but possibly from other sources,
462        // so `locate()` can fall back to use `span`.
463        CowStr::Borrowed(s) => {
464            // FIXME: remove this function once pulldown_cmark can provide spans for link definitions.
465            unsafe {
466                let s_start = dest.as_ptr();
467                let s_end = s_start.add(s.len());
468                let md_start = md.as_ptr();
469                let md_end = md_start.add(md.len());
470                if md_start <= s_start && s_end <= md_end {
471                    let start = s_start.offset_from(md_start) as usize;
472                    let end = s_end.offset_from(md_start) as usize;
473                    start..end
474                } else {
475                    link_range
476                }
477            }
478        }
479
480        // For anything else, we can only use the provided range.
481        CowStr::Boxed(_) | CowStr::Inlined(_) => link_range,
482    }
483}