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 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 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 continue;
111 };
112
113 if &link_data.display_link.replace('`', "") != resolvable_link {
114 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
182fn 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 .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 Some((_, true)) => return Ok(()),
257 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 Some((_, true)) => return Ok(()),
275 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
301fn 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 .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 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 Some((_, true)) => return Ok(()),
375 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 Some((_, true)) => return Ok(()),
393 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 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, ¤t_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
497fn 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 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 (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 match dest {
592 CowStr::Borrowed(s) => {
597 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 CowStr::Boxed(_) | CowStr::Inlined(_) => link_range,
615 }
616}