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