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