cargo_docs_md/multi_crate/context.rs
1//! Multi-crate generation context.
2//!
3//! This module provides [`MultiCrateContext`] which holds shared state
4//! during multi-crate documentation generation, and [`SingleCrateView`]
5//! which provides a single-crate interface for existing rendering code.
6
7use std::collections::HashMap;
8use std::fmt::Write;
9use std::sync::LazyLock;
10
11use regex::Regex;
12use rustdoc_types::{Crate, Id, Impl, Item, ItemEnum, Visibility};
13use tracing::{debug, instrument, trace};
14
15use crate::Args;
16use crate::generator::doc_links::{
17 convert_html_links, convert_path_reference_links, strip_duplicate_title,
18 strip_reference_definitions, unhide_code_lines,
19};
20use crate::generator::{ItemAccess, ItemFilter, LinkResolver};
21use crate::linker::{item_has_anchor, LinkRegistry, slugify_anchor};
22use crate::multi_crate::{CrateCollection, UnifiedLinkRegistry};
23
24/// Regex for backtick code links: [`Name`] not followed by ( or [
25static BACKTICK_LINK_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[`([^`]+)`\]").unwrap());
26
27/// Regex for plain links [name] where name is `snake_case`
28static PLAIN_LINK_RE: LazyLock<Regex> =
29 LazyLock::new(|| Regex::new(r"\[([a-z][a-z0-9_]*)\]").unwrap());
30
31/// Shared context for multi-crate documentation generation.
32///
33/// Holds references to all crates, the unified link registry, and
34/// CLI configuration. Used by [`MultiCrateGenerator`] to coordinate
35/// generation across crates.
36///
37/// [`MultiCrateGenerator`]: crate::multi_crate::MultiCrateGenerator
38pub struct MultiCrateContext<'a> {
39 /// All crates being documented.
40 crates: &'a CrateCollection,
41
42 /// Unified link registry for cross-crate resolution.
43 registry: UnifiedLinkRegistry,
44
45 /// CLI arguments.
46 args: &'a Args,
47
48 /// Pre-computed cross-crate impl blocks.
49 ///
50 /// Maps target crate name -> type name -> impl blocks from other crates.
51 /// This is computed once during construction rather than per-view.
52 cross_crate_impls: HashMap<String, HashMap<String, Vec<&'a Impl>>>,
53}
54
55impl<'a> MultiCrateContext<'a> {
56 /// Create a new multi-crate context.
57 ///
58 /// Builds the unified link registry and pre-computes cross-crate impls.
59 #[must_use]
60 #[instrument(skip(crates, args), fields(crate_count = crates.names().len()))]
61 pub fn new(crates: &'a CrateCollection, args: &'a Args) -> Self {
62 debug!("Creating multi-crate context");
63
64 let primary = args.primary_crate.as_deref();
65 let registry = UnifiedLinkRegistry::build(crates, primary);
66
67 // Pre-compute cross-crate impls for all crates
68 debug!("Building cross-crate impl map");
69 let cross_crate_impls = Self::build_cross_crate_impls(crates);
70
71 debug!(
72 cross_crate_impl_count = cross_crate_impls.values().map(HashMap::len).sum::<usize>(),
73 "Multi-crate context created"
74 );
75
76 Self {
77 crates,
78 registry,
79 args,
80 cross_crate_impls,
81 }
82 }
83
84 /// Build the cross-crate impl map for all crates.
85 ///
86 /// Scans all crates once and groups impl blocks by their target crate
87 /// and type name. This avoids O(n*m) scanning per view creation.
88 fn build_cross_crate_impls(
89 crates: &'a CrateCollection,
90 ) -> HashMap<String, HashMap<String, Vec<&'a Impl>>> {
91 let mut result: HashMap<String, HashMap<String, Vec<&'a Impl>>> = HashMap::new();
92
93 // Initialize empty maps for all crates
94 for crate_name in crates.names() {
95 result.insert(crate_name.clone(), HashMap::new());
96 }
97
98 // Scan all crates for impl blocks
99 for (source_crate, krate) in crates.iter() {
100 for item in krate.index.values() {
101 if let ItemEnum::Impl(impl_block) = &item.inner {
102 // Skip synthetic impls
103 if impl_block.is_synthetic {
104 continue;
105 }
106
107 // Get the target type path
108 if let Some(type_path) = Self::get_impl_target_path(impl_block) {
109 // Extract the target crate name (first segment)
110 if let Some(target_crate) = type_path.split("::").next() {
111 // Skip if targeting same crate (not cross-crate)
112 if target_crate == source_crate {
113 continue;
114 }
115
116 // Only add if target crate is in our collection
117 if let Some(type_map) = result.get_mut(target_crate) {
118 // Extract the type name (last segment)
119 let type_name = type_path
120 .split("::")
121 .last()
122 .unwrap_or(&type_path)
123 .to_string();
124
125 type_map.entry(type_name).or_default().push(impl_block);
126 }
127 }
128 }
129 }
130 }
131 }
132
133 result
134 }
135
136 /// Get the crate collection.
137 #[must_use]
138 pub const fn crates(&self) -> &CrateCollection {
139 self.crates
140 }
141
142 /// Get the unified link registry.
143 #[must_use]
144 pub const fn registry(&self) -> &UnifiedLinkRegistry {
145 &self.registry
146 }
147
148 /// Get CLI arguments.
149 #[must_use]
150 pub const fn args(&self) -> &Args {
151 self.args
152 }
153
154 /// Create a single-crate view for rendering one crate.
155 ///
156 /// This bridges multi-crate mode to existing single-crate rendering
157 /// code by providing a compatible interface that uses the unified
158 /// registry for cross-crate link resolution.
159 #[must_use]
160 pub fn single_crate_view(&'a self, crate_name: &str) -> Option<SingleCrateView<'a>> {
161 // Use get_with_name to get the crate name with the collection's lifetime
162 let (name, krate) = self.crates.get_with_name(crate_name)?;
163
164 Some(SingleCrateView::new(
165 name,
166 krate,
167 &self.registry,
168 self.args,
169 self,
170 ))
171 }
172
173 /// Find an item across all crates by ID.
174 ///
175 /// Searches through all crates in the collection to find an item with
176 /// the given ID. This is useful for resolving re-exports that point to
177 /// items in external crates.
178 ///
179 /// # Returns
180 ///
181 /// A tuple of `(crate_name, item)` if found, or `None` if the item
182 /// doesn't exist in any crate.
183 #[must_use]
184 pub fn find_item(&self, id: &Id) -> Option<(&str, &Item)> {
185 for (crate_name, krate) in self.crates.iter() {
186 if let Some(item) = krate.index.get(id) {
187 return Some((crate_name, item));
188 }
189 }
190 None
191 }
192
193 /// Get pre-computed cross-crate impl blocks for a target crate.
194 ///
195 /// Returns a map from type name to impl blocks from other crates.
196 /// This data is pre-computed during context construction for efficiency.
197 ///
198 /// # Returns
199 ///
200 /// Reference to the type-name -> impl-blocks map, or `None` if the
201 /// crate is not in the collection.
202 #[must_use]
203 pub fn get_cross_crate_impls(
204 &self,
205 target_crate: &str,
206 ) -> Option<&HashMap<String, Vec<&'a Impl>>> {
207 self.cross_crate_impls.get(target_crate)
208 }
209
210 /// Get the target type path for an impl block.
211 fn get_impl_target_path(impl_block: &Impl) -> Option<String> {
212 use rustdoc_types::Type;
213
214 match &impl_block.for_ {
215 Type::ResolvedPath(path) => Some(path.path.clone()),
216 _ => None,
217 }
218 }
219}
220
221/// View of a single crate within multi-crate context.
222///
223/// Provides an interface similar to [`GeneratorContext`] but uses
224/// [`UnifiedLinkRegistry`] for cross-crate link resolution. This
225/// allows existing rendering code to work with minimal changes.
226///
227/// [`GeneratorContext`]: crate::generator::GeneratorContext
228pub struct SingleCrateView<'a> {
229 /// Name of this crate (borrowed from the context).
230 crate_name: &'a str,
231
232 /// The crate being rendered.
233 krate: &'a Crate,
234
235 /// Unified registry for link resolution.
236 registry: &'a UnifiedLinkRegistry,
237
238 /// CLI arguments.
239 args: &'a Args,
240
241 /// Reference to the parent multi-crate context for cross-crate lookups.
242 ctx: &'a MultiCrateContext<'a>,
243
244 /// Map from type ID to impl blocks (local crate only).
245 impl_map: HashMap<Id, Vec<&'a Impl>>,
246
247 /// Reference to pre-computed cross-crate impl blocks from context.
248 /// Maps type name to impl blocks from other crates.
249 cross_crate_impls: Option<&'a HashMap<String, Vec<&'a Impl>>>,
250
251 /// Map from type name to type ID for cross-crate impl lookup.
252 type_name_to_id: HashMap<String, Id>,
253}
254
255impl<'a> SingleCrateView<'a> {
256 /// Create a new single-crate view.
257 fn new(
258 crate_name: &'a str,
259 krate: &'a Crate,
260 registry: &'a UnifiedLinkRegistry,
261 args: &'a Args,
262 ctx: &'a MultiCrateContext<'a>,
263 ) -> Self {
264 // Get reference to pre-computed cross-crate impls
265 let cross_crate_impls = ctx.get_cross_crate_impls(crate_name);
266
267 let mut view = Self {
268 crate_name,
269 krate,
270 registry,
271 args,
272 ctx,
273 impl_map: HashMap::new(),
274 cross_crate_impls,
275 type_name_to_id: HashMap::new(),
276 };
277
278 view.build_impl_map();
279 view.build_type_name_map();
280
281 view
282 }
283
284 /// Build the impl map for all types.
285 fn build_impl_map(&mut self) {
286 self.impl_map.clear();
287
288 for item in self.krate.index.values() {
289 if let ItemEnum::Impl(impl_block) = &item.inner
290 && let Some(target_id) = Self::get_impl_target_id(impl_block)
291 {
292 self.impl_map.entry(target_id).or_default().push(impl_block);
293 }
294 }
295
296 // Sort impl blocks for deterministic output
297 for impls in self.impl_map.values_mut() {
298 impls.sort_by_key(|i| Self::impl_sort_key(i));
299 // Deduplicate impls with the same sort key
300 impls.dedup_by(|a, b| Self::impl_sort_key(a) == Self::impl_sort_key(b));
301 }
302 }
303
304 /// Build a map from type name to type ID.
305 ///
306 /// This is used to look up cross-crate impls by type name.
307 fn build_type_name_map(&mut self) {
308 self.type_name_to_id.clear();
309
310 for (id, item) in &self.krate.index {
311 if let Some(name) = &item.name {
312 // Only include types that can have impls
313 match &item.inner {
314 ItemEnum::Struct(_) | ItemEnum::Enum(_) | ItemEnum::Union(_) => {
315 self.type_name_to_id.insert(name.clone(), *id);
316 },
317 _ => {},
318 }
319 }
320 }
321 }
322
323 /// Get the target type ID for an impl block.
324 const fn get_impl_target_id(impl_block: &Impl) -> Option<Id> {
325 use rustdoc_types::Type;
326
327 match &impl_block.for_ {
328 Type::ResolvedPath(path) => Some(path.id),
329 _ => None,
330 }
331 }
332
333 /// Generate a sort key for impl blocks.
334 fn impl_sort_key(impl_block: &Impl) -> (u8, String) {
335 // Extract trait name from the path (last segment)
336 let trait_name: String = impl_block
337 .trait_
338 .as_ref()
339 .and_then(|p| p.path.split("::").last())
340 .unwrap_or("")
341 .to_string();
342
343 let priority = if impl_block.trait_.is_none() {
344 0 // Inherent impls first
345 } else if trait_name.starts_with("From") || trait_name.starts_with("Into") {
346 1 // Conversion traits
347 } else if trait_name.starts_with("De") || trait_name.starts_with("Se") {
348 3 // Serde traits last
349 } else {
350 2 // Other traits
351 };
352
353 (priority, trait_name)
354 }
355
356 /// Get the crate name.
357 #[must_use]
358 pub const fn crate_name(&self) -> &str {
359 self.crate_name
360 }
361
362 /// Get the crate being rendered.
363 #[must_use]
364 pub const fn krate(&self) -> &Crate {
365 self.krate
366 }
367
368 /// Get the unified registry.
369 #[must_use]
370 pub const fn registry(&self) -> &UnifiedLinkRegistry {
371 self.registry
372 }
373
374 /// Get CLI arguments.
375 #[must_use]
376 pub const fn args(&self) -> &Args {
377 self.args
378 }
379
380 /// Get impl blocks for a type (local crate only).
381 #[must_use]
382 pub fn get_impls(&self, id: Id) -> Option<&Vec<&'a Impl>> {
383 self.impl_map.get(&id)
384 }
385
386 /// Get all impl blocks for a type, including cross-crate impls.
387 ///
388 /// This method merges local impls (from this crate) with impls from
389 /// other crates that implement traits for this type.
390 #[must_use]
391 pub fn get_all_impls(&self, id: Id) -> Vec<&'a Impl> {
392 let mut result = Vec::new();
393
394 // Add local impls
395 if let Some(local_impls) = self.impl_map.get(&id) {
396 result.extend(local_impls.iter().copied());
397 }
398
399 // Add cross-crate impls by looking up the type name
400 if let Some(item) = self.krate.index.get(&id)
401 && let Some(type_name) = &item.name
402 && let Some(cross_crate_map) = self.cross_crate_impls
403 && let Some(external_impls) = cross_crate_map.get(type_name)
404 {
405 result.extend(external_impls.iter().copied());
406 }
407
408 result
409 }
410
411 /// Get impl blocks for a type from a specific crate.
412 ///
413 /// This is used for cross-crate re-exports where we need to look up
414 /// impl blocks from the source crate rather than the current crate.
415 ///
416 /// # Arguments
417 ///
418 /// * `id` - The ID of the type to get impls for
419 /// * `source_krate` - The crate to look up impls from
420 ///
421 /// # Returns
422 ///
423 /// A vector of impl blocks found in the source crate for the given type ID.
424 #[must_use]
425 pub fn get_impls_from_crate(&self, id: Id, source_krate: &'a Crate) -> Vec<&'a Impl> {
426 let mut result = Vec::new();
427
428 // Scan the source crate for impl blocks targeting this ID
429 for item in source_krate.index.values() {
430 if let ItemEnum::Impl(impl_block) = &item.inner {
431 // Check if this impl targets our type using existing helper
432 if let Some(target_id) = Self::get_impl_target_id_from_type(&impl_block.for_)
433 && target_id == id
434 {
435 result.push(impl_block);
436 }
437 }
438 }
439
440 // Also include cross-crate impls if this is our current crate
441 if std::ptr::eq(source_krate, self.krate)
442 && let Some(item) = self.krate.index.get(&id)
443 && let Some(type_name) = &item.name
444 && let Some(cross_crate_map) = self.cross_crate_impls
445 && let Some(external_impls) = cross_crate_map.get(type_name)
446 {
447 result.extend(external_impls.iter().copied());
448 }
449
450 result
451 }
452
453 /// Extract the target ID from a Type (for impl block matching).
454 const fn get_impl_target_id_from_type(ty: &rustdoc_types::Type) -> Option<Id> {
455 use rustdoc_types::Type;
456
457 match ty {
458 Type::ResolvedPath(path) => Some(path.id),
459 _ => None,
460 }
461 }
462
463 /// Check if an item should be included based on visibility.
464 #[must_use]
465 pub const fn should_include_item(&self, item: &rustdoc_types::Item) -> bool {
466 if self.args.exclude_private {
467 return matches!(item.visibility, Visibility::Public);
468 }
469
470 true
471 }
472
473 /// Count modules for progress reporting.
474 #[must_use]
475 pub fn count_modules(&self) -> usize {
476 self.krate
477 .index
478 .values()
479 .filter(|item| matches!(&item.inner, ItemEnum::Module(_)))
480 .count()
481 }
482
483 /// Create a markdown link using the unified registry.
484 #[must_use]
485 pub fn create_link(&self, to_crate: &str, to_id: Id, from_path: &str) -> Option<String> {
486 self.registry
487 .create_link(self.crate_name, from_path, to_crate, to_id)
488 }
489
490 /// Resolve a name to a crate and ID.
491 #[must_use]
492 pub fn resolve_name(&self, name: &str) -> Option<(String, Id)> {
493 self.registry
494 .resolve_name(name, self.crate_name)
495 .map(|(s, id)| (s.to_string(), id))
496 }
497
498 /// Look up an item across all crates by ID.
499 ///
500 /// This is useful for resolving re-exports that point to items in
501 /// external crates. First checks the local crate, then searches
502 /// all other crates in the collection.
503 ///
504 /// # Returns
505 ///
506 /// A tuple of `(crate_name, item)` if found, or `None` if the item
507 /// doesn't exist in any crate.
508 #[must_use]
509 #[instrument(skip(self), fields(crate_name = %self.crate_name), level = "trace")]
510 pub fn lookup_item_across_crates(&self, id: &Id) -> Option<(&str, &Item)> {
511 // First check local crate (fast path)
512 if let Some(item) = self.krate.index.get(id) {
513 trace!(found_in = "local", "Item found in local crate");
514 return Some((self.crate_name, item));
515 }
516
517 // Fall back to searching all crates
518 trace!("Item not in local crate, searching all crates");
519 let result = self.ctx.find_item(id);
520
521 if let Some((crate_name, _)) = &result {
522 debug!(found_in = %crate_name, "Item found in external crate");
523 } else {
524 trace!("Item not found in any crate");
525 }
526
527 result
528 }
529
530 /// Get a crate by name from the collection.
531 ///
532 /// This is useful for getting the source crate context when rendering
533 /// re-exported items from other crates.
534 ///
535 /// # Returns
536 ///
537 /// The crate if found, or `None` if no crate with that name exists.
538 #[must_use]
539 pub fn get_crate(&self, name: &str) -> Option<&Crate> {
540 self.ctx.crates.get(name)
541 }
542
543 /// Resolve a path like `regex_automata::Regex` to an item.
544 ///
545 /// This is used for external re-exports where `use_item.id` is `None`
546 /// but the source path is available.
547 ///
548 /// # Returns
549 ///
550 /// A tuple of `(source_crate, item, item_id)` if found.
551 #[must_use]
552 pub fn resolve_external_path(&self, path: &str) -> Option<(&str, &Item, Id)> {
553 let (source_crate, id) = self.registry.resolve_path(path)?;
554 let (crate_name, item) = self.ctx.find_item(&id)?;
555
556 // Verify the crate matches
557 if crate_name == source_crate {
558 Some((crate_name, item, id))
559 } else {
560 None
561 }
562 }
563
564 /// Process backtick links like `[`Span`]` to markdown links.
565 #[tracing::instrument(skip(self, docs, item_links), level = "trace", fields(file = %current_file))]
566 fn process_backtick_links(
567 &self,
568 docs: &str,
569 item_links: &HashMap<String, Id>,
570 current_file: &str,
571 ) -> String {
572 let mut result = String::with_capacity(docs.len());
573 let mut last_end = 0;
574 let mut resolved_count = 0;
575 let mut unresolved_count = 0;
576
577 for caps in BACKTICK_LINK_RE.captures_iter(docs) {
578 let full_match = caps.get(0).unwrap();
579 let match_start = full_match.start();
580 let match_end = full_match.end();
581
582 // Check if followed by ( or [ (already a link)
583 let next_char = docs[match_end..].chars().next();
584 if matches!(next_char, Some('(' | '[')) {
585 tracing::trace!(
586 link = %full_match.as_str(),
587 "Skipping - already has link target"
588 );
589 continue;
590 }
591
592 result.push_str(&docs[last_end..match_start]);
593 last_end = match_end;
594
595 let link_text = &caps[1];
596
597 // The item_links keys may have backticks (e.g., "`Visit`") or not ("Visit")
598 // Try the backtick-wrapped version first since that's what rustdoc typically uses
599 let backtick_key = format!("`{link_text}`");
600
601 // Try to resolve the link (try backtick version first, then plain)
602 if let Some(resolved) = self
603 .resolve_link(&backtick_key, item_links, current_file)
604 .or_else(|| self.resolve_link(link_text, item_links, current_file))
605 {
606 tracing::trace!(
607 link_text = %link_text,
608 resolved = %resolved,
609 "Resolved backtick link"
610 );
611 resolved_count += 1;
612 result.push_str(&resolved);
613 } else {
614 tracing::trace!(
615 link_text = %link_text,
616 "Could not resolve backtick link, keeping as inline code"
617 );
618 unresolved_count += 1;
619 // Couldn't resolve - convert to plain inline code
620 _ = write!(result, "`{link_text}`");
621 }
622 }
623
624 result.push_str(&docs[last_end..]);
625
626 if resolved_count > 0 || unresolved_count > 0 {
627 tracing::trace!(
628 resolved = resolved_count,
629 unresolved = unresolved_count,
630 "Finished processing backtick links"
631 );
632 }
633
634 result
635 }
636
637 /// Process plain links like `[enter]` to markdown links.
638 ///
639 /// Uses the registry to resolve links to proper paths. If the item exists
640 /// in the registry, creates a link to its location. If on the current page
641 /// and has a heading anchor, uses an anchor link.
642 ///
643 /// Skips matches that are:
644 /// - Inside inline code (backticks)
645 /// - Already markdown links (followed by `(` or `[`)
646 fn process_plain_links(&self, docs: &str, current_file: &str) -> String {
647 let mut result = String::with_capacity(docs.len());
648 let mut last_end = 0;
649
650 for caps in PLAIN_LINK_RE.captures_iter(docs) {
651 let full_match = caps.get(0).unwrap();
652 let match_start = full_match.start();
653 let match_end = full_match.end();
654
655 // Check if followed by ( or [ (already a link)
656 let next_char = docs[match_end..].chars().next();
657 if matches!(next_char, Some('(' | '[')) {
658 continue;
659 }
660
661 // Check if inside inline code (count backticks before match)
662 let before = &docs[..match_start];
663 let backtick_count = before.chars().filter(|&c| c == '`').count();
664 if backtick_count % 2 == 1 {
665 // Odd number of backticks means we're inside inline code
666 continue;
667 }
668
669 result.push_str(&docs[last_end..match_start]);
670 last_end = match_end;
671
672 let link_text = &caps[1];
673
674 // Try to resolve via registry
675 if let Some(link) = self.resolve_plain_link(link_text, current_file) {
676 result.push_str(&link);
677 } else {
678 // Unresolved - keep as plain text
679 _ = write!(result, "[{link_text}]");
680 }
681 }
682
683 result.push_str(&docs[last_end..]);
684 result
685 }
686
687 /// Resolve a plain link `[name]` to a markdown link.
688 ///
689 /// Returns `Some(markdown_link)` if the item can be resolved,
690 /// `None` if it should remain as plain text.
691 #[tracing::instrument(skip(self), level = "trace")]
692 fn resolve_plain_link(&self, link_text: &str, current_file: &str) -> Option<String> {
693 // Try to find the item in the registry
694 let (resolved_crate, id) = self.registry.resolve_name(link_text, self.crate_name)?;
695
696 tracing::trace!(
697 resolved_crate = %resolved_crate,
698 id = ?id,
699 "Found item in registry"
700 );
701
702 // Check if this is an external re-export and try to follow it
703 let (target_crate, target_id) = self
704 .registry
705 .resolve_reexport(&resolved_crate, id)
706 .unwrap_or_else(|| (resolved_crate.clone(), id));
707
708 let followed_reexport = target_crate != resolved_crate || target_id != id;
709 if followed_reexport {
710 tracing::trace!(
711 original_crate = %resolved_crate,
712 original_id = ?id,
713 target_crate = %target_crate,
714 target_id = ?target_id,
715 "Followed re-export chain to original item"
716 );
717 }
718
719 // Get the crate data for the target (might be different from current crate)
720 let target_krate = self.ctx.crates.get(&target_crate)?;
721
722 // Get the item's path info from the target crate
723 let path_info = target_krate.paths.get(&target_id)?;
724
725 // Get the file path for this item
726 let target_path = self.registry.get_path(&target_crate, target_id)?;
727
728 // Strip crate prefix from current_file for comparison
729 let current_local = Self::strip_crate_prefix(current_file);
730
731 // Check if same file (accounting for cross-crate)
732 let is_same_file = target_crate == self.crate_name && target_path == current_local;
733
734 if is_same_file {
735 // Item is on the current page
736 if item_has_anchor(path_info.kind) {
737 // Has a heading - create anchor link
738 let anchor = slugify_anchor(link_text);
739 tracing::trace!(
740 anchor = %anchor,
741 kind = ?path_info.kind,
742 "Creating same-page anchor link"
743 );
744 Some(format!("[{link_text}](#{anchor})"))
745 } else {
746 // No heading - link to page without anchor
747 tracing::trace!(
748 kind = ?path_info.kind,
749 "Item on same page but no heading - linking to page"
750 );
751 Some(format!("[{link_text}]()"))
752 }
753 } else {
754 // Item is in a different file (possibly different crate)
755 tracing::trace!(
756 target_crate = %target_crate,
757 target_path = %target_path,
758 "Creating cross-file link"
759 );
760 let relative = self.build_markdown_link(
761 current_file,
762 &target_crate,
763 target_path,
764 link_text,
765 None,
766 );
767 Some(relative)
768 }
769 }
770
771 /// Resolve a link text to a markdown link using the registry.
772 ///
773 /// This function attempts to convert rustdoc link syntax into valid markdown
774 /// links that work in the generated documentation.
775 ///
776 /// # Arguments
777 /// * `link_text` - The raw link target from rustdoc (e.g., "`crate::config::ConfigBuilder::method`")
778 /// * `item_links` - Map of link texts to Item IDs from rustdoc's `links` field
779 /// * `current_file` - The markdown file being generated (e.g., "ureq/index.md")
780 ///
781 /// # Returns
782 /// * `Some(markdown_link)` - A formatted markdown link like `[`text`](path.md#anchor)`
783 /// * `None` - If the link cannot be resolved (will be rendered as inline code)
784 ///
785 /// # Examples
786 ///
787 /// ```text
788 /// Input: link_text = "crate::config::ConfigBuilder::http_status_as_error"
789 /// current_file = "ureq/index.md"
790 /// Output: Some("[`crate::config::ConfigBuilder::http_status_as_error`](config/index.md#http_status_as_error)")
791 ///
792 /// Input: link_text = "ConfigBuilder"
793 /// current_file = "ureq/agent/index.md"
794 /// Output: Some("[`ConfigBuilder`](../config/index.md#configbuilder)")
795 ///
796 /// Input: link_text = "std::io::Error" (external crate, not in registry)
797 /// current_file = "ureq/index.md"
798 /// Output: None (rendered as `std::io::Error` inline code)
799 /// ```
800 #[instrument(skip(self, item_links), fields(crate_name = %self.crate_name))]
801 fn resolve_link(
802 &self,
803 link_text: &str,
804 item_links: &HashMap<String, Id>,
805 current_file: &str,
806 ) -> Option<String> {
807 // ─────────────────────────────────────────────────────────────────────
808 // Strategy 1: Try the item's links map (most accurate)
809 // ─────────────────────────────────────────────────────────────────────
810 // Rustdoc provides a `links` map on each item that maps link text to
811 // the resolved Item ID. This is the most reliable source because rustdoc
812 // has already done the name resolution.
813 //
814 // Example item_links map:
815 // {
816 // "ConfigBuilder" => Id(123),
817 // "crate::config::ConfigBuilder" => Id(123),
818 // "Agent" => Id(456)
819 // }
820 tracing::trace!(
821 strategy = "item_links",
822 "Attempting resolution via item links map"
823 );
824 if let Some(id) = item_links.get(link_text) {
825 // We have an ID! Now convert it to a markdown path.
826 // Example: Id(123) → "config/index.md" → "[`ConfigBuilder`](config/index.md)"
827 tracing::debug!(strategy = "item_links", ?id, "Found ID in item links");
828
829 // Strip backticks from display name if present (rustdoc uses `Name` as keys)
830 let display_name = link_text.trim_matches('`');
831 if let Some(link) = self.build_link_to_id(*id, current_file, display_name, None) {
832 tracing::debug!(strategy = "item_links", link = %link, "Successfully resolved");
833
834 return Some(link);
835 }
836
837 tracing::trace!(strategy = "item_links", "ID Found but couldn't build link");
838 }
839
840 // ─────────────────────────────────────────────────────────────────────
841 // Strategy 2: Try resolving by name in the registry
842 // ─────────────────────────────────────────────────────────────────────
843 // If the item_links map didn't have this link (can happen with re-exports
844 // or manually written links), try looking up the name directly in our
845 // cross-crate registry.
846 //
847 // Example:
848 // link_text = "Agent"
849 // registry.resolve_name("Agent", "ureq") → Some(("ureq", Id(456)))
850 tracing::trace!(
851 strategy = "registry_name",
852 "Attempting resolution via registry name lookup"
853 );
854 if let Some((resolved_crate, id)) = self.registry.resolve_name(link_text, self.crate_name) {
855 // Only use this if:
856 // 1. Same crate (internal link), OR
857 // 2. Explicitly looks like an external reference (contains "::")
858 //
859 // This prevents accidental cross-crate linking for common names like "Error"
860 if resolved_crate == self.crate_name || Self::looks_like_external_reference(link_text) {
861 // Use build_link_to_id to follow re-exports to the original definition
862 if let Some(link) = self.build_link_to_id(id, current_file, link_text, None) {
863 return Some(link);
864 }
865 }
866 }
867
868 // ─────────────────────────────────────────────────────────────────────
869 // Strategy 3: Try crate:: prefixed paths
870 // ─────────────────────────────────────────────────────────────────────
871 // Handle explicit crate-relative paths like "crate::config::ConfigBuilder::method"
872 // These are common in rustdoc comments and need special parsing.
873 //
874 // Example:
875 // link_text = "crate::config::ConfigBuilder::http_status_as_error"
876 // → strip prefix → "config::ConfigBuilder::http_status_as_error"
877 // → resolve_crate_path() handles the rest
878 if let Some(path_without_crate) = link_text.strip_prefix("crate::")
879 && let Some(link) = self.resolve_crate_path(path_without_crate, link_text, current_file)
880 {
881 return Some(link);
882 }
883
884 // ─────────────────────────────────────────────────────────────────────
885 // Give up on qualified paths we can't resolve
886 // ─────────────────────────────────────────────────────────────────────
887 // If it has "::" and we still haven't resolved it, it's probably an
888 // external crate we don't have (like std, serde, tokio, etc.)
889 // Return None so it renders as inline code: `std::io::Error`
890 if link_text.contains("::") {
891 return None;
892 }
893
894 // ─────────────────────────────────────────────────────────────────────
895 // Fallback: anchor on current page (only if item has a heading)
896 // ─────────────────────────────────────────────────────────────────────
897 // For simple names without ::, check if the item exists and has a heading.
898 // Only structs, enums, traits, functions, etc. get headings.
899 // Methods, fields, and variants don't have headings (they're bullet points).
900 if let Some((_, id)) = self.registry.resolve_name(link_text, self.crate_name) {
901 if let Some(path_info) = self.krate.paths.get(&id) {
902 if item_has_anchor(path_info.kind) {
903 return Some(format!("[`{link_text}`](#{})", slugify_anchor(link_text)));
904 }
905 // Item exists but no anchor - link to page without anchor
906 return Some(format!("[`{link_text}`]()"));
907 }
908 }
909 // Unknown item - return None (renders as inline code)
910 None
911 }
912
913 /// Build a link to an item by ID.
914 ///
915 /// This is the simplest path when we already have a resolved Item ID from
916 /// rustdoc's links map. We just need to look up the file path in our registry.
917 ///
918 /// # Arguments
919 /// * `id` - The rustdoc Item ID to link to
920 /// * `current_file` - Source file for relative path computation
921 /// * `display_name` - Text to show in the link
922 /// * `anchor` - Optional anchor (e.g., method name)
923 ///
924 /// # Example Transformation
925 ///
926 /// ```text
927 /// Input:
928 /// id = Id(123) (rustdoc's internal ID for ConfigBuilder)
929 /// current_file = "ureq/agent/index.md"
930 /// display_name = "ConfigBuilder"
931 /// anchor = None
932 ///
933 /// Step 1: Look up ID in registry
934 /// registry.get_path("ureq", Id(123)) → Some("config/index.md")
935 ///
936 /// Step 2: Build markdown link
937 /// build_markdown_link("ureq/agent/index.md", "ureq", "config/index.md", "ConfigBuilder", None)
938 /// → "[`ConfigBuilder`](../config/index.md)"
939 ///
940 /// Output: Some("[`ConfigBuilder`](../config/index.md)")
941 /// ```
942 #[tracing::instrument(skip(self), level = "trace")]
943 fn build_link_to_id(
944 &self,
945 id: Id,
946 current_file: &str,
947 display_name: &str,
948 anchor: Option<&str>,
949 ) -> Option<String> {
950 // First: Check if this is a re-export and follow to the original definition
951 // Re-exports don't have headings - we need to link to where the item is defined
952 //
953 // Method 1: Check our re_export_sources registry
954 if let Some((original_crate, original_id)) =
955 self.registry.resolve_reexport(self.crate_name, id)
956 {
957 tracing::trace!(
958 original_crate = %original_crate,
959 original_id = ?original_id,
960 "Following re-export via registry to original definition"
961 );
962
963 if let Some(target_path) = self.registry.get_path(&original_crate, original_id) {
964 return Some(self.build_markdown_link(
965 current_file,
966 &original_crate,
967 target_path,
968 display_name,
969 anchor,
970 ));
971 }
972 }
973
974 // Method 2: Check if the item itself is a Use item in the index
975 if let Some(item) = self.krate.index.get(&id)
976 && let ItemEnum::Use(use_item) = &item.inner
977 {
978 tracing::trace!(
979 source = %use_item.source,
980 target_id = ?use_item.id,
981 "Found Use item in index"
982 );
983
984 // Method 2a: If the Use item has a target ID, look up via paths
985 // This handles cases where source is relative (e.g., "self::event::Event")
986 // but the ID points to the actual item in another crate
987 if let Some(target_id) = use_item.id {
988 if let Some(path_info) = self.krate.paths.get(&target_id) {
989 if let Some(external_crate) = path_info.path.first() {
990 tracing::trace!(
991 external_crate = %external_crate,
992 path = ?path_info.path,
993 "Following Use item target ID to external crate"
994 );
995
996 // Try to find the item in the external crate by name
997 let item_name = path_info.path.last().unwrap_or(&path_info.path[0]);
998 if let Some((resolved_crate, resolved_id)) =
999 self.registry.resolve_name(item_name, external_crate)
1000 {
1001 if let Some(target_path) =
1002 self.registry.get_path(&resolved_crate, resolved_id)
1003 {
1004 return Some(self.build_markdown_link(
1005 current_file,
1006 &resolved_crate,
1007 target_path,
1008 display_name,
1009 anchor,
1010 ));
1011 }
1012 }
1013 }
1014 }
1015 }
1016
1017 // Method 2b: Try to resolve the source path directly
1018 if !use_item.source.is_empty() {
1019 if let Some((original_crate, original_id)) =
1020 self.registry.resolve_path(&use_item.source)
1021 {
1022 if let Some(target_path) =
1023 self.registry.get_path(&original_crate, original_id)
1024 {
1025 return Some(self.build_markdown_link(
1026 current_file,
1027 &original_crate,
1028 target_path,
1029 display_name,
1030 anchor,
1031 ));
1032 }
1033 }
1034 }
1035 }
1036
1037 // Strategy 1: Try to find the ID in the current crate
1038 if let Some(target_path) = self.registry.get_path(self.crate_name, id) {
1039 tracing::trace!(
1040 strategy = "current_crate",
1041 crate_name = %self.crate_name,
1042 target_path = %target_path,
1043 "Found ID in current crate registry"
1044 );
1045 return Some(self.build_markdown_link(
1046 current_file,
1047 self.crate_name,
1048 target_path,
1049 display_name,
1050 anchor,
1051 ));
1052 }
1053
1054 tracing::trace!(
1055 strategy = "current_crate",
1056 crate_name = %self.crate_name,
1057 "ID not found in current crate, checking paths for external reference"
1058 );
1059
1060 // Strategy 2: ID not in current crate - check if it's an external item via paths
1061 // The paths map can contain IDs from other crates (for re-exports/cross-refs)
1062 if let Some(path_info) = self.krate.paths.get(&id) {
1063 // path_info.path is like ["tracing_core", "field", "Visit"]
1064 // First element is the crate name
1065 let path_str = path_info.path.join("::");
1066 tracing::trace!(
1067 strategy = "external_paths",
1068 path = %path_str,
1069 kind = ?path_info.kind,
1070 "Found path info for external item"
1071 );
1072
1073 if let Some(external_crate) = path_info.path.first() {
1074 // Strategy 2a: Try direct ID lookup in external crate
1075 if let Some(target_path) = self.registry.get_path(external_crate, id) {
1076 tracing::trace!(
1077 strategy = "external_direct_id",
1078 external_crate = %external_crate,
1079 target_path = %target_path,
1080 "Found external item by direct ID lookup"
1081 );
1082 return Some(self.build_markdown_link(
1083 current_file,
1084 external_crate,
1085 target_path,
1086 display_name,
1087 anchor,
1088 ));
1089 }
1090
1091 // Strategy 2b: External crate uses different ID - try name-based lookup
1092 // This handles cross-crate references where IDs are crate-local
1093 let item_name = path_info.path.last()?;
1094 tracing::trace!(
1095 strategy = "external_name_lookup",
1096 external_crate = %external_crate,
1097 item_name = %item_name,
1098 "Attempting name-based lookup in external crate"
1099 );
1100
1101 if let Some((resolved_crate, resolved_id)) =
1102 self.registry.resolve_name(item_name, external_crate)
1103 {
1104 tracing::trace!(
1105 strategy = "external_name_lookup",
1106 resolved_crate = %resolved_crate,
1107 resolved_id = ?resolved_id,
1108 "Name resolved to crate and ID"
1109 );
1110
1111 if let Some(target_path) = self.registry.get_path(&resolved_crate, resolved_id)
1112 {
1113 tracing::debug!(
1114 strategy = "external_name_lookup",
1115 resolved_crate = %resolved_crate,
1116 target_path = %target_path,
1117 "Successfully resolved external item"
1118 );
1119 return Some(self.build_markdown_link(
1120 current_file,
1121 &resolved_crate,
1122 target_path,
1123 display_name,
1124 anchor,
1125 ));
1126 }
1127
1128 tracing::trace!(
1129 strategy = "external_name_lookup",
1130 resolved_crate = %resolved_crate,
1131 resolved_id = ?resolved_id,
1132 "Name resolved but no path found in registry"
1133 );
1134 } else {
1135 tracing::trace!(
1136 strategy = "external_name_lookup",
1137 external_crate = %external_crate,
1138 item_name = %item_name,
1139 "Name not found in external crate registry"
1140 );
1141 }
1142 }
1143 } else {
1144 tracing::trace!(
1145 strategy = "external_paths",
1146 "No path info found for ID"
1147 );
1148 }
1149
1150 tracing::trace!("All strategies exhausted, returning None");
1151 None
1152 }
1153
1154 /// Resolve `crate::path::Item` or `crate::path::Item::method` patterns.
1155 ///
1156 /// This handles the common rustdoc pattern where docs reference items using
1157 /// crate-relative paths. The tricky part is distinguishing between:
1158 /// - `crate::module::Type` (link to Type, no anchor)
1159 /// - `crate::module::Type::method` (link to Type with #method anchor)
1160 /// - `crate::module::Type::Variant` (link to Type with #Variant anchor)
1161 ///
1162 /// # Arguments
1163 /// * `path_without_crate` - The path after stripping "`crate::`" prefix
1164 /// * `display_name` - Full original text for display (includes "`crate::`")
1165 /// * `current_file` - Source file for relative path computation
1166 ///
1167 /// # Example Transformation
1168 ///
1169 /// ```text
1170 /// Input:
1171 /// path_without_crate = "config::ConfigBuilder::http_status_as_error"
1172 /// display_name = "crate::config::ConfigBuilder::http_status_as_error"
1173 /// current_file = "ureq/index.md"
1174 ///
1175 /// Step 1: Split into type path and anchor
1176 /// split_type_and_anchor("config::ConfigBuilder::http_status_as_error")
1177 /// → ("config::ConfigBuilder", Some("http_status_as_error"))
1178 /// (lowercase "http_status_as_error" indicates a method)
1179 ///
1180 /// Step 2: Extract the type name (last segment of type path)
1181 /// "config::ConfigBuilder".rsplit("::").next() → "ConfigBuilder"
1182 ///
1183 /// Step 3: Resolve type name in registry
1184 /// registry.resolve_name("ConfigBuilder", "ureq") → Some(("ureq", Id(123)))
1185 /// registry.get_path("ureq", Id(123)) → Some("config/index.md")
1186 ///
1187 /// Step 4: Build markdown link with anchor
1188 /// build_markdown_link("ureq/index.md", "ureq", "config/index.md",
1189 /// "crate::config::ConfigBuilder::http_status_as_error",
1190 /// Some("http_status_as_error"))
1191 /// → "[`crate::config::ConfigBuilder::http_status_as_error`](config/index.md#http_status_as_error)"
1192 ///
1193 /// Output: Some("[`crate::config::ConfigBuilder::http_status_as_error`](config/index.md#http_status_as_error)")
1194 /// ```
1195 fn resolve_crate_path(
1196 &self,
1197 path_without_crate: &str,
1198 display_name: &str,
1199 current_file: &str,
1200 ) -> Option<String> {
1201 // Step 1: Separate the type path from any method/variant anchor
1202 // "config::ConfigBuilder::method" → ("config::ConfigBuilder", Some("method"))
1203 let (type_path, anchor) = Self::split_type_and_anchor(path_without_crate);
1204
1205 // Step 2: Get just the type name (we'll search for this in the registry)
1206 // "config::ConfigBuilder" → "ConfigBuilder"
1207 let type_name = type_path.rsplit("::").next()?;
1208
1209 // Step 3: Look up the type in our cross-crate registry
1210 // This finds which crate owns "ConfigBuilder" and what file it's in
1211 let (resolved_crate, id) = self.registry.resolve_name(type_name, self.crate_name)?;
1212 let target_path = self.registry.get_path(&resolved_crate, id)?;
1213
1214 // Step 4: Build the final markdown link
1215 Some(self.build_markdown_link(
1216 current_file,
1217 &resolved_crate,
1218 target_path,
1219 display_name,
1220 anchor,
1221 ))
1222 }
1223
1224 /// Split `config::ConfigBuilder::method` into (`config::ConfigBuilder`, Some("method")).
1225 ///
1226 /// Detects methods (lowercase) and enum variants (`Type::Variant` pattern).
1227 ///
1228 /// # Detection Rules
1229 ///
1230 /// 1. **Methods/fields**: Last segment starts with lowercase
1231 /// - `Type::method` → (Type, method)
1232 /// - `mod::Type::field_name` → (`mod::Type`, `field_name`)
1233 ///
1234 /// 2. **Enum variants**: Two consecutive uppercase segments
1235 /// - `Option::Some` → (Option, Some)
1236 /// - `mod::Error::IoError` → (`mod::Error`, `IoError`)
1237 ///
1238 /// 3. **Nested types**: Uppercase but no uppercase predecessor
1239 /// - `mod::OuterType::InnerType` → (`mod::OuterType::InnerType`, None)
1240 ///
1241 /// # Examples
1242 ///
1243 /// ```text
1244 /// "ConfigBuilder::http_status_as_error"
1245 /// Last segment "http_status_as_error" starts lowercase → method
1246 /// → ("ConfigBuilder", Some("http_status_as_error"))
1247 ///
1248 /// "config::ConfigBuilder::new"
1249 /// Last segment "new" starts lowercase → method
1250 /// → ("config::ConfigBuilder", Some("new"))
1251 ///
1252 /// "Option::Some"
1253 /// "Option" uppercase, "Some" uppercase → enum variant
1254 /// → ("Option", Some("Some"))
1255 ///
1256 /// "error::Error::Io"
1257 /// "Error" uppercase, "Io" uppercase → enum variant
1258 /// → ("error::Error", Some("Io"))
1259 ///
1260 /// "config::ConfigBuilder"
1261 /// "config" lowercase, "ConfigBuilder" uppercase → not a variant
1262 /// → ("config::ConfigBuilder", None)
1263 ///
1264 /// "Vec"
1265 /// No "::" separator
1266 /// → ("Vec", None)
1267 /// ```
1268 fn split_type_and_anchor(path: &str) -> (&str, Option<&str>) {
1269 // Find the last "::" separator
1270 // "config::ConfigBuilder::method" → sep_pos = 21 (before "method")
1271 let Some(sep_pos) = path.rfind("::") else {
1272 // No separator, just a simple name like "Vec"
1273 return (path, None);
1274 };
1275
1276 // Split into: rest = "config::ConfigBuilder", last = "method"
1277 let last = &path[sep_pos + 2..]; // Skip the "::"
1278 let rest = &path[..sep_pos];
1279
1280 // ─────────────────────────────────────────────────────────────────────
1281 // Rule 1: Lowercase last segment = method/field
1282 // ─────────────────────────────────────────────────────────────────────
1283 // Methods and fields in Rust are snake_case by convention
1284 if last.starts_with(|c: char| c.is_lowercase()) {
1285 return (rest, Some(last));
1286 }
1287
1288 // ─────────────────────────────────────────────────────────────────────
1289 // Rule 2: Check for enum variant (Type::Variant pattern)
1290 // ─────────────────────────────────────────────────────────────────────
1291 // Both the type and variant are uppercase (PascalCase)
1292
1293 // Check if there's another "::" before this one
1294 // "error::Error::Io" → prev_sep at position of "Error", prev = "Error"
1295 if let Some(prev_sep) = rest.rfind("::") {
1296 let prev = &rest[prev_sep + 2..]; // The segment before "last"
1297
1298 // Both uppercase = likely Type::Variant
1299 // "Error" uppercase + "Io" uppercase → enum variant
1300 if prev.starts_with(|c: char| c.is_uppercase())
1301 && last.starts_with(|c: char| c.is_uppercase())
1302 {
1303 return (rest, Some(last));
1304 }
1305 } else if rest.starts_with(|c: char| c.is_uppercase())
1306 && last.starts_with(|c: char| c.is_uppercase())
1307 {
1308 // Simple case: "Option::Some" with no module prefix
1309 // "Option" uppercase + "Some" uppercase → enum variant
1310 return (rest, Some(last));
1311 }
1312
1313 // ─────────────────────────────────────────────────────────────────────
1314 // No anchor detected
1315 // ─────────────────────────────────────────────────────────────────────
1316 // This is something like "mod::Type" where Type is not a variant
1317 (path, None)
1318 }
1319
1320 /// Build a markdown link, handling same-crate and cross-crate cases.
1321 ///
1322 /// This is the core function that computes relative paths between markdown
1323 /// files and formats the final link.
1324 ///
1325 /// # Arguments
1326 /// * `current_file` - The file we're generating (e.g., "ureq/agent/index.md")
1327 /// * `target_crate` - The crate containing the target item
1328 /// * `target_path` - Path to target within its crate (e.g., "config/index.md")
1329 /// * `display_name` - Text to show in the link
1330 /// * `anchor` - Optional anchor suffix (e.g., "`method_name`")
1331 ///
1332 /// # Path Computation Examples
1333 ///
1334 /// ## Same Crate Examples
1335 ///
1336 /// ```text
1337 /// Example 1: Link from index to nested module
1338 /// current_file = "ureq/index.md"
1339 /// target_crate = "ureq"
1340 /// target_path = "config/index.md"
1341 ///
1342 /// Step 1: Strip crate prefix from current
1343 /// "ureq/index.md" -> "index.md"
1344 ///
1345 /// Step 2: Compute relative path
1346 /// from "index.md" to "config/index.md"
1347 /// -> "config/index.md"
1348 ///
1349 /// Output: "[`display`](config/index.md)"
1350 ///
1351 /// Example 2: Link from nested to sibling module
1352 /// current_file = "ureq/agent/index.md"
1353 /// target_crate = "ureq"
1354 /// target_path = "config/index.md"
1355 ///
1356 /// Step 1: Strip crate prefix
1357 /// "ureq/agent/index.md" -> "agent/index.md"
1358 ///
1359 /// Step 2: Compute relative path
1360 /// from "agent/index.md" to "config/index.md"
1361 /// -> "config/index.md"
1362 ///
1363 /// Output: "[`display`][../config/index.md]"
1364 ///
1365 /// ## Cross-Crate Examples
1366 ///
1367 /// ```text
1368 /// Example 3: Link from one crate to another
1369 /// current_file = "ureq/agent/index.md"
1370 /// target_crate = "http"
1371 /// target_path = "status/index.md"
1372 ///
1373 /// Step 1: Strip crate prefix
1374 /// "ureq/agent/index.md" → "agent/index.md"
1375 ///
1376 /// Step 2: Count depth (number of '/' in local path)
1377 /// "agent/index.md" has 1 slash → depth = 1
1378 ///
1379 /// Step 3: Build cross-crate path
1380 /// Go up (depth + 1) levels: "../" * 2 = "../../"
1381 /// Then into target crate: "../../http/status/index.md"
1382 ///
1383 /// Output: "[`display`](../../http/status/index.md)"
1384 ///
1385 /// Example 4: Cross-crate from root
1386 /// current_file = "ureq/index.md"
1387 /// target_crate = "http"
1388 /// target_path = "index.md"
1389 ///
1390 /// depth = 0 (no slashes in "index.md")
1391 /// prefix = "../" * 1 = "../"
1392 ///
1393 /// Output: "[`display`](../http/index.md)"
1394 /// ```
1395 fn build_markdown_link(
1396 &self,
1397 current_file: &str,
1398 target_crate: &str,
1399 target_path: &str,
1400 display_name: &str,
1401 anchor: Option<&str>,
1402 ) -> String {
1403 use crate::linker::LinkRegistry;
1404
1405 // ------------------------------------------------------------------------
1406 // Step 1: Get the crate-local portion of the current path
1407 // ------------------------------------------------------------------------
1408 // "ureq/agent/index.md" -> "agent/index.md"
1409 // This is needed because target_path doesn't include the crate prefix
1410 let current_local = Self::strip_crate_prefix(current_file);
1411
1412 // ------------------------------------------------------------------------
1413 // Step 2: Compute the file path portion of the link
1414 // ------------------------------------------------------------------------
1415 let file_link = if target_crate == self.crate_name {
1416 // ====================================================================
1417 // SAME CRATE: Use relative path within the crate
1418 // ====================================================================
1419 if current_local == target_path {
1420 // Same file, we only need an anchor, no file path.
1421 // Example: linking to a method on the same page
1422 String::new()
1423 } else {
1424 // Different file in same crate - compute relative path
1425 // "agent/index.md" -> "config/index.md" = "../config/index.md"
1426 LinkRegistry::compute_relative_path(current_local, target_path)
1427 }
1428 } else {
1429 // ================================================================
1430 // CROSS-CRATE: Navigate up to docs root, then into target crate
1431 // ================================================================
1432 Self::compute_cross_crate_path(current_local, target_crate, target_path)
1433 };
1434
1435 // ─────────────────────────────────────────────────────────────────────
1436 // Step 3: Build the anchor suffix
1437 // ─────────────────────────────────────────────────────────────────────
1438 // Convert anchor to slug format (lowercase, hyphens for special chars)
1439 // "http_status_as_error" → "#http_status_as_error"
1440 let anchor_suffix = anchor.map_or_else(String::new, |a| format!("#{}", slugify_anchor(a)));
1441
1442 // ─────────────────────────────────────────────────────────────────────
1443 // Step 4: Assemble the final markdown link
1444 // ─────────────────────────────────────────────────────────────────────
1445 if file_link.is_empty() {
1446 // Same file - we need an anchor (either explicit or from display name)
1447 // If no explicit anchor was provided, use the display name as anchor
1448 let anchor = if anchor.is_some() {
1449 anchor_suffix
1450 } else {
1451 // Turn display name into anchor: "ConfigBuilder" → "#configbuilder"
1452 format!("#{}", slugify_anchor(display_name))
1453 };
1454 format!("[`{display_name}`]({anchor})")
1455 } else {
1456 // Different file - include file path and optional anchor
1457 format!("[`{display_name}`]({file_link}{anchor_suffix})")
1458 }
1459 }
1460
1461 /// Compute a relative path for cross-crate linking.
1462 ///
1463 /// Given the local portion of the current file path (without crate prefix),
1464 /// computes the `../` prefix needed to navigate to another crate's file.
1465 ///
1466 /// # Arguments
1467 /// * `current_local` - Current file path within crate (e.g., "agent/index.md")
1468 /// * `target_crate` - Name of the target crate
1469 /// * `target_path` - Path within target crate (e.g., "status/index.md")
1470 ///
1471 /// # Examples
1472 ///
1473 /// ```text
1474 /// // From root of one crate to another
1475 /// compute_cross_crate_path("index.md", "http", "index.md")
1476 /// → "../http/index.md"
1477 ///
1478 /// // From nested module to another crate
1479 /// compute_cross_crate_path("agent/index.md", "http", "status/index.md")
1480 /// → "../../http/status/index.md"
1481 ///
1482 /// // From deeply nested to another crate root
1483 /// compute_cross_crate_path("a/b/c/index.md", "other", "index.md")
1484 /// → "../../../../other/index.md"
1485 /// ```
1486 fn compute_cross_crate_path(
1487 current_local: &str,
1488 target_crate: &str,
1489 target_path: &str,
1490 ) -> String {
1491 // Count depth: number of '/' in current path
1492 // "agent/index.md" has 1 slash → depth = 1
1493 let depth = current_local.matches('/').count();
1494
1495 // We need to go up:
1496 // - `depth` levels to get to crate root
1497 // - +1 more level to get to docs root (above all crates)
1498 let prefix = "../".repeat(depth + 1);
1499
1500 // Then descend into the target crate
1501 format!("{prefix}{target_crate}/{target_path}")
1502 }
1503
1504 /// Strip the crate prefix from a file path.
1505 ///
1506 /// File paths in our system includes the crate name as the first directory.
1507 /// This helper removes it to get the crate-local path.
1508 ///
1509 /// # Examples
1510 ///
1511 /// ```text
1512 /// "ureq/config/index.md" -> "config/index.md"
1513 /// "ureq/index.md" -> "index.md"
1514 /// "http/status/index.md" -> "status/index.md"
1515 /// "simple.md" -> "simple.md" (no slash returns as is)
1516 /// ```
1517 #[inline]
1518 fn strip_crate_prefix(path: &str) -> &str {
1519 // Find the first '/' which seperates crate name from the rest
1520 // "ureq/config/index.md"
1521 // ^ position = 4
1522 //
1523 // Then return everything after it: "config/index.md"
1524 path.find('/').map_or(path, |i| &path[(i + 1)..])
1525 }
1526
1527 /// Check if a link text looks like an intentional external crate reference.
1528 ///
1529 /// Simple names like "Wide", "Error", "Default" are often meant to be
1530 /// local anchors or type aliases, not cross-crate links.
1531 fn looks_like_external_reference(link_text: &str) -> bool {
1532 // Contains :: - explicit path reference
1533 if link_text.contains("::") {
1534 return true;
1535 }
1536
1537 // Known external crate names or patterns
1538 let external_patterns = ["std::", "core::", "alloc::", "_crate", "_derive", "_impl"];
1539
1540 for pattern in external_patterns {
1541 if link_text.contains(pattern) {
1542 return true;
1543 }
1544 }
1545
1546 // Single PascalCase words are usually local items, not external
1547 // (External items would be referenced with full paths)
1548 false
1549 }
1550}
1551
1552impl ItemAccess for SingleCrateView<'_> {
1553 fn krate(&self) -> &Crate {
1554 self.krate
1555 }
1556
1557 fn crate_name(&self) -> &str {
1558 self.crate_name
1559 }
1560
1561 fn get_item(&self, id: &Id) -> Option<&Item> {
1562 self.krate.index.get(id)
1563 }
1564
1565 fn get_impls(&self, id: &Id) -> Option<&[&Impl]> {
1566 self.impl_map.get(id).map(Vec::as_slice)
1567 }
1568
1569 fn crate_version(&self) -> Option<&str> {
1570 self.krate.crate_version.as_deref()
1571 }
1572}
1573
1574impl ItemFilter for SingleCrateView<'_> {
1575 fn should_include_item(&self, item: &Item) -> bool {
1576 match &item.visibility {
1577 Visibility::Public => true,
1578 _ => !self.args.exclude_private,
1579 }
1580 }
1581
1582 fn include_private(&self) -> bool {
1583 !self.args.exclude_private
1584 }
1585
1586 fn include_blanket_impls(&self) -> bool {
1587 self.args.include_blanket_impls
1588 }
1589}
1590
1591impl LinkResolver for SingleCrateView<'_> {
1592 fn link_registry(&self) -> Option<&LinkRegistry> {
1593 // Multi-crate mode uses UnifiedLinkRegistry instead
1594 None
1595 }
1596
1597 fn process_docs(&self, item: &Item, current_file: &str) -> Option<String> {
1598 let docs = item.docs.as_ref()?;
1599 let name = item.name.as_deref().unwrap_or("");
1600
1601 // Strip duplicate title if docs start with "# name"
1602 let docs = strip_duplicate_title(docs, name);
1603
1604 // Strip reference definitions first to prevent mangled output
1605 let stripped = strip_reference_definitions(docs);
1606
1607 // Unhide rustdoc hidden lines and add `rust` to bare code fences
1608 let unhidden = unhide_code_lines(&stripped);
1609
1610 // Convert HTML and path reference links
1611 let html_processed = convert_html_links(&unhidden);
1612 let path_processed = convert_path_reference_links(&html_processed);
1613
1614 // Process backtick links [`Name`]
1615 let backtick_processed =
1616 self.process_backtick_links(&path_processed, &item.links, current_file);
1617
1618 // Process plain links [name]
1619 let plain_processed = self.process_plain_links(&backtick_processed, current_file);
1620
1621 Some(plain_processed)
1622 }
1623
1624 fn create_link(&self, id: Id, current_file: &str) -> Option<String> {
1625 use crate::linker::LinkRegistry;
1626
1627 // Look up path in the unified registry (crate-local, no prefix)
1628 let target_path = self.registry.get_path(self.crate_name, id)?;
1629
1630 // Get the item name for display
1631 let display_name = self
1632 .registry
1633 .get_name(self.crate_name, id)
1634 .map_or("item", |s| s.as_str());
1635
1636 // Strip crate prefix from current_file to get crate-local path
1637 // "crate_name/module/index.md" -> "module/index.md"
1638 let current_local = Self::strip_crate_prefix(current_file);
1639
1640 // Compute relative path using the same logic as build_markdown_link
1641 let relative_path = if current_local == target_path.as_str() {
1642 // Same file - just use anchor
1643 format!("#{}", slugify_anchor(display_name))
1644 } else {
1645 // Different file - compute relative path within crate
1646 LinkRegistry::compute_relative_path(current_local, target_path)
1647 };
1648
1649 Some(format!("[`{display_name}`]({relative_path})"))
1650 }
1651}
1652
1653// SingleCrateView automatically implements RenderContext via blanket impl
1654
1655#[cfg(test)]
1656mod tests {
1657 use super::*;
1658
1659 // =========================================================================
1660 // Tests for split_type_and_anchor
1661 // =========================================================================
1662
1663 mod split_type_and_anchor {
1664 use super::*;
1665
1666 #[test]
1667 fn simple_type_no_anchor() {
1668 assert_eq!(SingleCrateView::split_type_and_anchor("Vec"), ("Vec", None));
1669 }
1670
1671 #[test]
1672 fn module_path_no_anchor() {
1673 // Module prefix + type = no anchor (lowercase then uppercase)
1674 assert_eq!(
1675 SingleCrateView::split_type_and_anchor("config::ConfigBuilder"),
1676 ("config::ConfigBuilder", None)
1677 );
1678 }
1679
1680 #[test]
1681 fn type_with_method() {
1682 // Type::method - last segment lowercase = method anchor
1683 assert_eq!(
1684 SingleCrateView::split_type_and_anchor("Type::method"),
1685 ("Type", Some("method"))
1686 );
1687 }
1688
1689 #[test]
1690 fn type_with_snake_case_method() {
1691 assert_eq!(
1692 SingleCrateView::split_type_and_anchor("ConfigBuilder::http_status_as_error"),
1693 ("ConfigBuilder", Some("http_status_as_error"))
1694 );
1695 }
1696
1697 #[test]
1698 fn module_type_method() {
1699 // Full path with method
1700 assert_eq!(
1701 SingleCrateView::split_type_and_anchor("config::ConfigBuilder::new"),
1702 ("config::ConfigBuilder", Some("new"))
1703 );
1704 }
1705
1706 #[test]
1707 fn enum_variant_simple() {
1708 // Both uppercase = enum variant
1709 assert_eq!(
1710 SingleCrateView::split_type_and_anchor("Option::Some"),
1711 ("Option", Some("Some"))
1712 );
1713 }
1714
1715 #[test]
1716 fn enum_variant_with_module() {
1717 // Module + Type::Variant
1718 assert_eq!(
1719 SingleCrateView::split_type_and_anchor("error::Error::Io"),
1720 ("error::Error", Some("Io"))
1721 );
1722 }
1723
1724 #[test]
1725 fn result_variant() {
1726 assert_eq!(
1727 SingleCrateView::split_type_and_anchor("Result::Ok"),
1728 ("Result", Some("Ok"))
1729 );
1730 }
1731
1732 #[test]
1733 fn nested_modules_with_type() {
1734 // Deep nesting ending in type (no anchor)
1735 assert_eq!(
1736 SingleCrateView::split_type_and_anchor("a::b::c::Type"),
1737 ("a::b::c::Type", None)
1738 );
1739 }
1740
1741 #[test]
1742 fn nested_modules_with_method() {
1743 // Deep nesting ending in method
1744 assert_eq!(
1745 SingleCrateView::split_type_and_anchor("a::b::Type::method"),
1746 ("a::b::Type", Some("method"))
1747 );
1748 }
1749
1750 #[test]
1751 fn associated_type_treated_as_variant() {
1752 // Iterator::Item - both uppercase, treated as variant (acceptable)
1753 assert_eq!(
1754 SingleCrateView::split_type_and_anchor("Iterator::Item"),
1755 ("Iterator", Some("Item"))
1756 );
1757 }
1758
1759 #[test]
1760 fn const_associated_item() {
1761 // Type::CONST - uppercase const, treated as variant
1762 assert_eq!(
1763 SingleCrateView::split_type_and_anchor("Type::MAX"),
1764 ("Type", Some("MAX"))
1765 );
1766 }
1767 }
1768
1769 // =========================================================================
1770 // Tests for strip_crate_prefix
1771 // =========================================================================
1772
1773 mod strip_crate_prefix {
1774 use super::*;
1775
1776 #[test]
1777 fn strips_crate_from_nested_path() {
1778 assert_eq!(
1779 SingleCrateView::strip_crate_prefix("ureq/config/index.md"),
1780 "config/index.md"
1781 );
1782 }
1783
1784 #[test]
1785 fn strips_crate_from_root() {
1786 assert_eq!(
1787 SingleCrateView::strip_crate_prefix("ureq/index.md"),
1788 "index.md"
1789 );
1790 }
1791
1792 #[test]
1793 fn strips_crate_from_deep_path() {
1794 assert_eq!(
1795 SingleCrateView::strip_crate_prefix("http/uri/authority/index.md"),
1796 "uri/authority/index.md"
1797 );
1798 }
1799
1800 #[test]
1801 fn no_slash_returns_as_is() {
1802 assert_eq!(
1803 SingleCrateView::strip_crate_prefix("simple.md"),
1804 "simple.md"
1805 );
1806 }
1807 }
1808
1809 // =========================================================================
1810 // Tests for looks_like_external_reference
1811 // =========================================================================
1812
1813 mod looks_like_external_reference {
1814 use super::*;
1815
1816 #[test]
1817 fn qualified_path_is_external() {
1818 assert!(SingleCrateView::looks_like_external_reference(
1819 "std::io::Error"
1820 ));
1821 }
1822
1823 #[test]
1824 fn crate_path_is_external() {
1825 assert!(SingleCrateView::looks_like_external_reference(
1826 "regex::Regex"
1827 ));
1828 }
1829
1830 #[test]
1831 fn std_prefix_is_external() {
1832 assert!(SingleCrateView::looks_like_external_reference(
1833 "std::vec::Vec"
1834 ));
1835 }
1836
1837 #[test]
1838 fn core_prefix_is_external() {
1839 assert!(SingleCrateView::looks_like_external_reference(
1840 "core::mem::drop"
1841 ));
1842 }
1843
1844 #[test]
1845 fn alloc_prefix_is_external() {
1846 assert!(SingleCrateView::looks_like_external_reference(
1847 "alloc::string::String"
1848 ));
1849 }
1850
1851 #[test]
1852 fn simple_name_not_external() {
1853 assert!(!SingleCrateView::looks_like_external_reference("Error"));
1854 }
1855
1856 #[test]
1857 fn pascal_case_not_external() {
1858 assert!(!SingleCrateView::looks_like_external_reference(
1859 "ConfigBuilder"
1860 ));
1861 }
1862
1863 #[test]
1864 fn derive_suffix_is_external() {
1865 assert!(SingleCrateView::looks_like_external_reference(
1866 "serde_derive"
1867 ));
1868 }
1869 }
1870
1871 // =========================================================================
1872 // Tests for compute_cross_crate_path (relative path computation)
1873 // =========================================================================
1874
1875 mod compute_cross_crate_path {
1876 use super::*;
1877
1878 #[test]
1879 fn from_root_to_root() {
1880 // From crate root (index.md) to another crate's root
1881 assert_eq!(
1882 SingleCrateView::compute_cross_crate_path("index.md", "http", "index.md"),
1883 "../http/index.md"
1884 );
1885 }
1886
1887 #[test]
1888 fn from_root_to_nested() {
1889 // From crate root to nested module in another crate
1890 assert_eq!(
1891 SingleCrateView::compute_cross_crate_path("index.md", "http", "status/index.md"),
1892 "../http/status/index.md"
1893 );
1894 }
1895
1896 #[test]
1897 fn from_nested_to_root() {
1898 // From nested module to another crate's root
1899 // depth = 1 (one '/'), needs "../" * 2 = "../../"
1900 assert_eq!(
1901 SingleCrateView::compute_cross_crate_path("agent/index.md", "http", "index.md"),
1902 "../../http/index.md"
1903 );
1904 }
1905
1906 #[test]
1907 fn from_nested_to_nested() {
1908 // From nested module to nested module in another crate
1909 assert_eq!(
1910 SingleCrateView::compute_cross_crate_path(
1911 "agent/index.md",
1912 "http",
1913 "status/index.md"
1914 ),
1915 "../../http/status/index.md"
1916 );
1917 }
1918
1919 #[test]
1920 fn from_deeply_nested() {
1921 // From deeply nested (3 levels) to another crate
1922 // depth = 3, needs "../" * 4 = "../../../../"
1923 assert_eq!(
1924 SingleCrateView::compute_cross_crate_path("a/b/c/index.md", "other", "index.md"),
1925 "../../../../other/index.md"
1926 );
1927 }
1928
1929 #[test]
1930 fn to_deeply_nested() {
1931 // From root to deeply nested in another crate
1932 assert_eq!(
1933 SingleCrateView::compute_cross_crate_path("index.md", "target", "x/y/z/index.md"),
1934 "../target/x/y/z/index.md"
1935 );
1936 }
1937
1938 #[test]
1939 fn both_deeply_nested() {
1940 // Both source and target are deeply nested
1941 assert_eq!(
1942 SingleCrateView::compute_cross_crate_path("a/b/index.md", "target", "x/y/index.md"),
1943 "../../../target/x/y/index.md"
1944 );
1945 }
1946 }
1947
1948 // Note: process_plain_links tests removed - function is now registry-aware
1949 // and requires a full SingleCrateView context. Behavior is tested via
1950 // integration tests.
1951}