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}