cargo_docs_md/
linker.rs

1//! Cross-reference linking for markdown documentation.
2//!
3//! This module provides the `LinkRegistry` which maps rustdoc item IDs to their
4//! corresponding markdown file paths. This enables creating clickable links
5//! between items in the generated documentation.
6//!
7//! # How It Works
8//!
9//! 1. During initialization, `LinkRegistry::build()` traverses the entire crate
10//!    and records where each item's documentation will be written.
11//!
12//! 2. During markdown generation, `create_link()` is called to generate
13//!    relative links from one file to another.
14//!
15//! # Path Formats
16//!
17//! The registry supports two output formats:
18//!
19//! - **Flat**: `module.md`, `parent__child.md` (double-underscore separators)
20//! - **Nested**: `module/index.md`, `parent/child/index.md` (directory structure)
21//!
22//! # Example
23//!
24//! ```ignore
25//! let registry = LinkRegistry::build(&krate, true); // flat format
26//! let link = registry.create_link(&some_id, "index.md");
27//! // Returns: Some("[`ItemName`](module.md)")
28//! ```
29
30use std::collections::HashMap;
31use std::path::Path;
32
33use rustdoc_types::{Crate, Id, ItemEnum, ItemKind, Visibility};
34use unicode_normalization::UnicodeNormalization;
35
36/// Kind of associated item for anchor generation.
37///
38/// Used to disambiguate anchors when multiple items share the same name
39/// (e.g., `type Init` and `fn init` in the same impl block).
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum AssocItemKind {
42    /// A method or function (`fn`)
43    Method,
44
45    /// An associated constant (`const`)
46    Const,
47
48    /// An associated type (`type`)
49    Type,
50}
51
52/// Context for generating impl item anchors, distinguishing inherent vs trait impls.
53///
54/// For trait impls, we need to include the trait name in the anchor to avoid
55/// duplicate anchors when multiple traits define the same associated type/const.
56/// For example, both `impl Add for Foo` and `impl Sub for Foo` might have
57/// `type Output`, which would create duplicate anchors without the trait name.
58#[derive(Debug, Clone, Copy)]
59pub enum ImplContext<'a> {
60    /// Inherent impl (no trait) - anchors use format `typename-itemname`
61    Inherent,
62
63    /// Trait impl - anchors include trait name: `typename-traitname-itemname`
64    Trait(&'a str),
65}
66
67/// Utilify functions to handle anchors
68pub struct AnchorUtils;
69
70impl AnchorUtils {
71    /// Generate a compound anchor for an associated item on a type.
72    ///
73    /// This creates a unique anchor that combines the type name, item kind, and item name,
74    /// enabling deep linking to specific items. The format is `typename-itemname` for methods
75    /// (backward compatible), and `typename-kind-itemname` for constants and types to avoid
76    /// collisions.
77    ///
78    /// # Arguments
79    ///
80    /// * `type_name` - The name of the type (struct, enum, trait, etc.)
81    /// * `item_name` - The name of the method or associated item
82    /// * `kind` - The kind of associated item (method, const, or type)
83    ///
84    /// # Examples
85    ///
86    /// ```ignore
87    /// assert_eq!(assoc_item_anchor("Parser", "parse", AssocItemKind::Method), "parser-parse");
88    /// assert_eq!(assoc_item_anchor("HashMap", "new", AssocItemKind::Method), "hashmap-new");
89    /// assert_eq!(assoc_item_anchor("Vec", "Item", AssocItemKind::Type), "vec-type-item");
90    /// assert_eq!(assoc_item_anchor("Vec", "ALIGN", AssocItemKind::Const), "vec-const-align");
91    /// ```
92    #[must_use]
93    pub fn assoc_item_anchor(type_name: &str, item_name: &str, kind: AssocItemKind) -> String {
94        let type_slug = Self::slugify_anchor(type_name);
95        let item_slug = Self::slugify_anchor(item_name);
96
97        match kind {
98            // Methods use the simple format for backward compatibility
99            AssocItemKind::Method => format!("{type_slug}-{item_slug}"),
100
101            // Constants and types include the kind to disambiguate from methods
102            AssocItemKind::Const => format!("{type_slug}-const-{item_slug}"),
103
104            AssocItemKind::Type => format!("{type_slug}-type-{item_slug}"),
105        }
106    }
107
108    /// Generate a compound anchor for a method on a type.
109    ///
110    /// This creates a unique anchor that combines the type name and method name,
111    /// enabling deep linking to specific methods. The format is `typename-methodname`,
112    /// where both parts are slugified.
113    ///
114    /// # Arguments
115    ///
116    /// * `type_name` - The name of the type (struct, enum, trait, etc.)
117    /// * `method_name` - The name of the method or associated item
118    ///
119    /// # Examples
120    ///
121    /// ```ignore
122    /// assert_eq!(method_anchor("Parser", "parse"), "parser-parse");
123    /// assert_eq!(method_anchor("HashMap", "new"), "hashmap-new");
124    /// assert_eq!(method_anchor("Vec<T>", "push"), "vec-push");
125    /// ```
126    #[must_use]
127    pub fn method_anchor(type_name: &str, method_name: &str) -> String {
128        Self::assoc_item_anchor(type_name, method_name, AssocItemKind::Method)
129    }
130
131    /// Generate an anchor for an associated item in an impl block, with trait disambiguation.
132    ///
133    /// This extends `assoc_item_anchor` to handle trait impls, where multiple traits
134    /// may define the same associated type (e.g., `Output` in both `Add` and `Sub`).
135    /// For trait impls, the trait name is included in the anchor to ensure uniqueness.
136    ///
137    /// # Arguments
138    ///
139    /// * `type_name` - The name of the implementing type
140    /// * `item_name` - The name of the associated item
141    /// * `kind` - The kind of associated item
142    /// * `impl_ctx` - Whether this is an inherent or trait impl
143    ///
144    /// # Anchor Formats
145    ///
146    /// | Context | Kind | Format | Example |
147    /// |---------|------|--------|---------|
148    /// | Inherent | Method | `type-method` | `vec-push` |
149    /// | Inherent | Type | `type-type-item` | `vec-type-item` |
150    /// | Inherent | Const | `type-const-item` | `vec-const-align` |
151    /// | Trait(Add) | Method | `type-add-method` | `vec-add-add` |
152    /// | Trait(Add) | Type | `type-add-type-item` | `vec-add-type-output` |
153    /// | Trait(Add) | Const | `type-add-const-item` | `vec-add-const-max` |
154    #[must_use]
155    pub fn impl_item_anchor(
156        type_name: &str,
157        item_name: &str,
158        kind: AssocItemKind,
159        impl_ctx: ImplContext<'_>,
160    ) -> String {
161        let type_slug = Self::slugify_anchor(type_name);
162        let item_slug = Self::slugify_anchor(item_name);
163
164        match impl_ctx {
165            // Inherent impls: use the existing format
166            ImplContext::Inherent => match kind {
167                AssocItemKind::Method => format!("{type_slug}-{item_slug}"),
168                AssocItemKind::Const => format!("{type_slug}-const-{item_slug}"),
169                AssocItemKind::Type => format!("{type_slug}-type-{item_slug}"),
170            },
171
172            // Trait impls: include trait name to avoid collisions
173            ImplContext::Trait(trait_name) => {
174                let trait_slug = Self::slugify_anchor(trait_name);
175                match kind {
176                    AssocItemKind::Method => format!("{type_slug}-{trait_slug}-{item_slug}"),
177                    AssocItemKind::Const => {
178                        format!("{type_slug}-{trait_slug}-const-{item_slug}")
179                    },
180                    AssocItemKind::Type => format!("{type_slug}-{trait_slug}-type-{item_slug}"),
181                }
182            },
183        }
184    }
185
186    /// Convert a name to a GitHub-style markdown anchor slug.
187    ///
188    /// This normalizes item names to match the anchor IDs generated by markdown
189    /// renderers (GitHub, mdBook, etc.) when they process headings.
190    ///
191    /// # Rules Applied
192    ///
193    /// 1. Apply Unicode NFC normalization (canonical composition)
194    /// 2. Convert to lowercase (full Unicode, not just ASCII)
195    /// 3. Remove backticks (markdown code formatting)
196    /// 4. Remove generics (`<T>`, `<K, V>`) by stripping `<...>` content
197    /// 5. Replace spaces and underscores with hyphens
198    /// 6. Remove non-alphanumeric characters (except hyphens)
199    /// 7. Collapse consecutive hyphens
200    /// 8. Trim leading/trailing hyphens
201    ///
202    /// # Examples
203    ///
204    /// ```ignore
205    /// assert_eq!(slugify_anchor("HashMap"), "hashmap");
206    /// assert_eq!(slugify_anchor("HashMap<K, V>"), "hashmap");
207    /// assert_eq!(slugify_anchor("my_function"), "my-function");
208    /// assert_eq!(slugify_anchor("Into<T>"), "into");
209    /// assert_eq!(slugify_anchor("Größe"), "größe");
210    /// ```
211    #[must_use]
212    pub fn slugify_anchor(name: &str) -> String {
213        // Fast path: pure ASCII strings (common for Rust identifiers)
214        // Skip NFC normalization overhead when not needed
215        if name.is_ascii() {
216            return Self::slugify_anchor_ascii(name);
217        }
218
219        // Slow path: Apply NFC normalization for Unicode strings
220        // Handles composed vs decomposed forms (e.g., "é" vs "e\u{0301}")
221        let normalized: String = name.nfc().collect();
222        Self::slugify_anchor_impl(&normalized)
223    }
224
225    /// Fast ASCII-only slugification (no allocation for normalization).
226    fn slugify_anchor_ascii(name: &str) -> String {
227        let mut result = String::with_capacity(name.len());
228        let mut in_generics = 0;
229        let mut last_was_hyphen = true;
230
231        for ch in name.chars() {
232            match ch {
233                '`' => {},
234
235                '<' => in_generics += 1,
236
237                '>' => {
238                    if in_generics > 0 {
239                        in_generics -= 1;
240                    }
241                },
242
243                _ if in_generics == 0 => {
244                    if ch.is_alphanumeric() {
245                        result.push(ch.to_ascii_lowercase());
246                        last_was_hyphen = false;
247                    } else if (ch == ' ' || ch == '_' || ch == '-') && !last_was_hyphen {
248                        result.push('-');
249                        last_was_hyphen = true;
250                    }
251                },
252
253                _ => {},
254            }
255        }
256
257        if result.ends_with('-') {
258            result.pop();
259        }
260
261        result
262    }
263
264    /// Unicode-aware slugification with full lowercase support.
265    fn slugify_anchor_impl(name: &str) -> String {
266        let mut result = String::with_capacity(name.len());
267        let mut in_generics = 0;
268        let mut last_was_hyphen = true;
269
270        for ch in name.chars() {
271            match ch {
272                '`' => {},
273
274                '<' => in_generics += 1,
275
276                '>' => {
277                    if in_generics > 0 {
278                        in_generics -= 1;
279                    }
280                },
281
282                _ if in_generics == 0 => {
283                    if ch.is_alphanumeric() {
284                        for lower_ch in ch.to_lowercase() {
285                            result.push(lower_ch);
286                        }
287
288                        last_was_hyphen = false;
289                    } else if (ch == ' ' || ch == '_' || ch == '-') && !last_was_hyphen {
290                        result.push('-');
291
292                        last_was_hyphen = true;
293                    }
294                },
295
296                _ => {},
297            }
298        }
299
300        if result.ends_with('-') {
301            result.pop();
302        }
303
304        result
305    }
306
307    /// Check if an item kind generates a heading anchor in markdown.
308    ///
309    /// Only certain item types get `### \`Name\` headings in the generated output.
310    /// Other items (methods, fields, variants) are rendered as bullet points
311    /// without heading anchors.
312    ///
313    /// # Items with anchors
314    ///
315    /// - Struct, Enum, Trait, Function, Constant, `TypeAlias`, Macro, Module
316    ///
317    /// # Items without anchors
318    ///
319    /// - Methods (in impl blocks)
320    /// - Struct fields
321    /// - Enum variants
322    /// - Associated types/constants
323    /// - Trait methods
324    #[must_use]
325    pub const fn item_has_anchor(kind: ItemKind) -> bool {
326        matches!(
327            kind,
328            ItemKind::Struct
329                | ItemKind::Enum
330                | ItemKind::Trait
331                | ItemKind::Function
332                | ItemKind::Constant
333                | ItemKind::TypeAlias
334                | ItemKind::Macro
335                | ItemKind::Module
336        )
337    }
338}
339
340/// Registry mapping item IDs to their documentation file paths.
341///
342/// This is the central data structure for cross-reference resolution.
343/// It's built once during generation and queried whenever we need to
344/// create links between items.
345#[derive(Debug, Default)]
346pub struct LinkRegistry {
347    /// Maps each item's ID to the markdown file path where it's documented.
348    ///
349    /// Paths are relative to the output directory root.
350    /// Examples: `"index.md"`, `"span.md"`, `"span/index.md"`
351    item_paths: HashMap<Id, String>,
352
353    /// Maps each item's ID to its display name.
354    ///
355    /// Used to generate the link text (e.g., `[`name`](path)`).
356    /// This is typically the item's identifier without the full path.
357    item_names: HashMap<Id, String>,
358}
359
360impl LinkRegistry {
361    /// Build a link registry by traversing all items in the crate.
362    ///
363    /// This function walks the module tree starting from the root and records
364    /// the file path where each item will be documented. The paths depend on
365    /// the output format (flat vs nested).
366    ///
367    /// # Arguments
368    ///
369    /// * `krate` - The parsed rustdoc crate containing all items
370    /// * `flat_format` - If true, use flat paths (`mod.md`); if false, use nested (`mod/index.md`)
371    /// * `include_private` - If true, include non-public items; if false, only public items
372    ///
373    /// # Returns
374    ///
375    /// A populated `LinkRegistry` ready for link creation.
376    ///
377    /// # Algorithm
378    ///
379    /// 1. Start at the crate root module
380    /// 2. For each top-level module: register it and recursively process children
381    /// 3. For structs/enums/traits at root level: register them to `index.md`
382    /// 4. Other items (functions, constants) are registered within their module's file
383    /// 5. Items are filtered by visibility unless `include_private` is true
384    #[must_use]
385    pub fn build(krate: &Crate, flat_format: bool, include_private: bool) -> Self {
386        let mut registry = Self::default();
387
388        // Get root module - if missing, return empty registry
389        let Some(root) = krate.index.get(&krate.root) else {
390            return registry;
391        };
392
393        // Process all items in the root module
394        if let ItemEnum::Module(module) = &root.inner {
395            for item_id in &module.items {
396                if let Some(item) = krate.index.get(item_id) {
397                    // Skip non-public items unless include_private is set
398                    if !include_private && !matches!(item.visibility, Visibility::Public) {
399                        continue;
400                    }
401
402                    match &item.inner {
403                        // Modules get their own files and are processed recursively
404                        ItemEnum::Module(_) => {
405                            let module_name = item.name.as_deref().unwrap_or("unnamed");
406
407                            // Determine the file path based on output format
408                            let path = if flat_format {
409                                format!("{module_name}.md")
410                            } else {
411                                format!("{module_name}/index.md")
412                            };
413
414                            // Recursively register this module and all its contents
415                            registry.register_module_items(
416                                krate,
417                                *item_id,
418                                item,
419                                &path,
420                                module_name,
421                                flat_format,
422                                include_private,
423                            );
424                        },
425
426                        // Top-level items are in the root index.md
427                        ItemEnum::Struct(_)
428                        | ItemEnum::Enum(_)
429                        | ItemEnum::Trait(_)
430                        | ItemEnum::Function(_)
431                        | ItemEnum::Constant { .. }
432                        | ItemEnum::TypeAlias(_)
433                        | ItemEnum::Macro(_) => {
434                            let name = item.name.as_deref().unwrap_or("unnamed");
435                            registry.item_paths.insert(*item_id, "index.md".to_string());
436                            registry.item_names.insert(*item_id, name.to_string());
437                        },
438
439                        // Re-exports (pub use) are registered with their alias name
440                        ItemEnum::Use(use_item) => {
441                            if use_item.is_glob {
442                                // Register items from glob re-export target
443                                if let Some(target_id) = &use_item.id
444                                    && let Some(target_module) = krate.index.get(target_id)
445                                    && let ItemEnum::Module(module) = &target_module.inner
446                                {
447                                    for child_id in &module.items {
448                                        if registry.item_paths.contains_key(child_id) {
449                                            continue; // Already registered
450                                        }
451
452                                        if let Some(child) = krate.index.get(child_id) {
453                                            if !include_private
454                                                && !matches!(child.visibility, Visibility::Public)
455                                            {
456                                                continue;
457                                            }
458
459                                            let name = child.name.as_deref().unwrap_or("unnamed");
460                                            registry
461                                                .item_paths
462                                                .insert(*child_id, "index.md".to_string());
463                                            registry.item_names.insert(*child_id, name.to_string());
464                                        }
465                                    }
466                                }
467                            } else {
468                                // Specific re-export - register both Use item AND target item
469                                let name = &use_item.name;
470                                registry.item_paths.insert(*item_id, "index.md".to_string());
471                                registry.item_names.insert(*item_id, name.clone());
472
473                                // Also register the target item's ID to this path
474                                // This ensures links to the target resolve to the re-export location
475                                if let Some(target_id) = &use_item.id
476                                    && !registry.item_paths.contains_key(target_id)
477                                {
478                                    registry
479                                        .item_paths
480                                        .insert(*target_id, "index.md".to_string());
481                                    registry.item_names.insert(*target_id, name.clone());
482                                }
483                            }
484                        },
485
486                        // Other items (primitives, etc.) don't need registration
487                        _ => {},
488                    }
489                }
490            }
491        }
492
493        registry
494    }
495
496    /// Recursively register all items within a module.
497    ///
498    /// This is called for each module in the crate to populate the registry
499    /// with all items that can be linked to.
500    ///
501    /// # Arguments
502    ///
503    /// * `krate` - The full crate for looking up item details
504    /// * `module_id` - ID of the module being registered
505    /// * `module_item` - The module's Item data
506    /// * `path` - File path where this module's docs will be written
507    /// * `module_prefix` - Prefix for building child paths (e.g., "parent" or "`parent__child`")
508    /// * `flat_format` - Whether to use flat naming convention
509    /// * `include_private` - Whether to include non-public items
510    #[expect(clippy::too_many_arguments, reason = "Complex method")]
511    fn register_module_items(
512        &mut self,
513        krate: &Crate,
514        module_id: Id,
515        module_item: &rustdoc_types::Item,
516        path: &str,
517        module_prefix: &str,
518        flat_format: bool,
519        include_private: bool,
520    ) {
521        // First, register the module itself
522        let module_name = module_item.name.as_deref().unwrap_or("unnamed");
523        self.item_paths.insert(module_id, path.to_string());
524        self.item_names.insert(module_id, module_name.to_string());
525
526        // Then register all items within this module
527        if let ItemEnum::Module(module) = &module_item.inner {
528            for item_id in &module.items {
529                if let Some(item) = krate.index.get(item_id) {
530                    // Skip non-public items unless include_private is set
531                    if !include_private && !matches!(item.visibility, Visibility::Public) {
532                        continue;
533                    }
534
535                    let name = item.name.as_deref().unwrap_or("unnamed");
536
537                    match &item.inner {
538                        // Types and functions within this module are documented in its file
539                        // They'll be linked with anchors (e.g., module.md#structname)
540                        ItemEnum::Struct(_)
541                        | ItemEnum::Enum(_)
542                        | ItemEnum::Trait(_)
543                        | ItemEnum::Function(_)
544                        | ItemEnum::Constant { .. }
545                        | ItemEnum::TypeAlias(_)
546                        | ItemEnum::Macro(_) => {
547                            self.item_paths.insert(*item_id, path.to_string());
548                            self.item_names.insert(*item_id, name.to_string());
549                        },
550
551                        // Re-exports (pub use) are registered with their alias name
552                        ItemEnum::Use(use_item) => {
553                            if use_item.is_glob {
554                                // Register items from glob re-export target
555                                self.register_glob_items(krate, use_item, path, include_private);
556                            } else {
557                                // Specific re-export - register both Use item AND target item
558                                self.item_paths.insert(*item_id, path.to_string());
559                                self.item_names.insert(*item_id, use_item.name.clone());
560
561                                // Also register the target item's ID to this path
562                                // This ensures links to the target resolve to the re-export location
563                                if let Some(target_id) = &use_item.id
564                                    && !self.item_paths.contains_key(target_id)
565                                {
566                                    self.item_paths.insert(*target_id, path.to_string());
567                                    self.item_names.insert(*target_id, use_item.name.clone());
568                                }
569                            }
570                        },
571
572                        // Nested modules get their own files - recurse into them
573                        ItemEnum::Module(_) => {
574                            // Build the file path for this submodule
575                            let sub_path = if flat_format {
576                                // Flat: parent__child__grandchild.md
577                                format!("{module_prefix}__{name}.md")
578                            } else {
579                                // Nested: parent/child/grandchild/index.md
580                                format!("{module_prefix}/{name}/index.md")
581                            };
582
583                            // Build the prefix for any further nesting
584                            let sub_prefix = if flat_format {
585                                format!("{module_prefix}__{name}")
586                            } else {
587                                format!("{module_prefix}/{name}")
588                            };
589
590                            // Recurse into the submodule
591                            self.register_module_items(
592                                krate,
593                                *item_id,
594                                item,
595                                &sub_path,
596                                &sub_prefix,
597                                flat_format,
598                                include_private,
599                            );
600                        },
601
602                        // Other item types (impl blocks, etc.) don't need registration
603                        _ => {},
604                    }
605                }
606            }
607        }
608    }
609
610    /// Register items from a glob re-export target module.
611    fn register_glob_items(
612        &mut self,
613        krate: &Crate,
614        use_item: &rustdoc_types::Use,
615        path: &str,
616        include_private: bool,
617    ) {
618        let Some(target_id) = &use_item.id else {
619            return;
620        };
621        let Some(target_module) = krate.index.get(target_id) else {
622            return;
623        };
624        let ItemEnum::Module(module) = &target_module.inner else {
625            return;
626        };
627
628        for child_id in &module.items {
629            // Skip if already registered
630            if self.item_paths.contains_key(child_id) {
631                continue;
632            }
633
634            let Some(child) = krate.index.get(child_id) else {
635                continue;
636            };
637
638            // Visibility filter
639            if !include_private && !matches!(child.visibility, Visibility::Public) {
640                continue;
641            }
642
643            let name = child.name.as_deref().unwrap_or("unnamed");
644            self.item_paths.insert(*child_id, path.to_string());
645            self.item_names.insert(*child_id, name.to_string());
646        }
647    }
648
649    /// Get the file path where an item is documented.
650    ///
651    /// # Arguments
652    ///
653    /// * `id` - The item's unique ID from rustdoc JSON
654    ///
655    /// # Returns
656    ///
657    /// The relative file path (e.g., `"span.md"` or `"span/index.md"`),
658    /// or `None` if the item isn't registered.
659    #[must_use]
660    pub fn get_path(&self, id: Id) -> Option<&String> {
661        self.item_paths.get(&id)
662    }
663
664    /// Get the display name for an item.
665    ///
666    /// # Arguments
667    ///
668    /// * `id` - The item's unique ID from rustdoc JSON
669    ///
670    /// # Returns
671    ///
672    /// The item's name for display in links (e.g., `"Span"`),
673    /// or `None` if the item isn't registered.
674    #[must_use]
675    pub fn get_name(&self, id: Id) -> Option<&String> {
676        self.item_names.get(&id)
677    }
678
679    /// Create a markdown link to an item from a given source file.
680    ///
681    /// This is the main method used during markdown generation to create
682    /// clickable links between documented items.
683    ///
684    /// # Arguments
685    ///
686    /// * `id` - The target item's ID
687    /// * `from_path` - The source file creating the link (e.g., `"index.md"`)
688    ///
689    /// # Returns
690    ///
691    /// A formatted markdown link like `[``ItemName``](path/to/file.md)`,
692    /// or `None` if the target item isn't registered.
693    ///
694    /// # Link Types
695    ///
696    /// - **Same file**: Returns an anchor link (`#itemname`)
697    /// - **Different file**: Returns a relative path (`../other/file.md`)
698    ///
699    /// # Example
700    ///
701    /// ```ignore
702    /// // From index.md linking to span.md
703    /// registry.create_link(&span_id, "index.md")
704    /// // Returns: Some("[`Span`](span.md)")
705    ///
706    /// // From span/index.md linking to index.md
707    /// registry.create_link(&root_id, "span/index.md")
708    /// // Returns: Some("[`crate`](../index.md)")
709    /// ```
710    #[must_use]
711    pub fn create_link(&self, id: Id, from_path: &str) -> Option<String> {
712        let target_path = self.item_paths.get(&id)?;
713        let name = self.item_names.get(&id)?;
714
715        // Calculate relative path from source to target file
716        let relative_path = Self::compute_relative_path(from_path, target_path);
717
718        // Determine the link destination:
719        // - Same file: use an anchor (#name)
720        // - Different file: use the relative path with anchor
721        let link = if target_path == from_path {
722            // Same file: use anchor only
723            format!("#{}", AnchorUtils::slugify_anchor(name))
724        } else {
725            // Different file: append anchor to path
726            format!("{}#{}", relative_path, AnchorUtils::slugify_anchor(name))
727        };
728
729        // Format as markdown link with backticks around the name
730        Some(format!("[`{name}`]({link})"))
731    }
732
733    /// Compute the relative path from one file to another.
734    ///
735    /// This function calculates the relative path needed to navigate from
736    /// one markdown file to another within the generated documentation.
737    /// Uses `pathdiff` for robust cross-platform path calculation.
738    ///
739    /// # Arguments
740    ///
741    /// * `from` - The source file path (e.g., `"span/index.md"`)
742    /// * `to` - The target file path (e.g., `"field/index.md"`)
743    ///
744    /// # Returns
745    ///
746    /// A relative path string (e.g., `"../field/index.md"`)
747    ///
748    /// # Examples
749    ///
750    /// - Same directory: `"index.md"` → `"span.md"` = `"span.md"`
751    /// - Into subdirectory: `"index.md"` → `"span/index.md"` = `"span/index.md"`
752    /// - Up to parent: `"span/index.md"` → `"index.md"` = `"../index.md"`
753    /// - Sibling directory: `"span/index.md"` → `"field/index.md"` = `"../field/index.md"`
754    #[must_use]
755    pub fn compute_relative_path(from: &str, to: &str) -> String {
756        // Same file - no path needed
757        if from == to {
758            return String::new();
759        }
760
761        // Get the directory containing 'from' (not the file itself)
762        let from_dir = Path::new(from).parent().unwrap_or_else(|| Path::new(""));
763
764        // Use pathdiff for robust relative path calculation
765        pathdiff::diff_paths(to, from_dir)
766            .map_or_else(|| to.to_string(), |p| p.to_string_lossy().into_owned())
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    /// Test: Files in the same directory need no path prefix.
775    #[test]
776    fn test_relative_path_same_dir() {
777        assert_eq!(
778            LinkRegistry::compute_relative_path("index.md", "span.md"),
779            "span.md"
780        );
781    }
782
783    /// Test: Linking from root into a subdirectory.
784    #[test]
785    fn test_relative_path_child_dir() {
786        assert_eq!(
787            LinkRegistry::compute_relative_path("index.md", "span/index.md"),
788            "span/index.md"
789        );
790    }
791
792    /// Test: Linking from subdirectory back to root.
793    #[test]
794    fn test_relative_path_parent_dir() {
795        assert_eq!(
796            LinkRegistry::compute_relative_path("span/index.md", "index.md"),
797            "../index.md"
798        );
799    }
800
801    /// Test: Linking between sibling directories.
802    #[test]
803    fn test_relative_path_sibling_dir() {
804        assert_eq!(
805            LinkRegistry::compute_relative_path("span/index.md", "field/index.md"),
806            "../field/index.md"
807        );
808    }
809
810    /// Test: Simple lowercase conversion.
811    #[test]
812    fn test_slugify_simple() {
813        assert_eq!(AnchorUtils::slugify_anchor("HashMap"), "hashmap");
814        assert_eq!(AnchorUtils::slugify_anchor("Span"), "span");
815    }
816
817    /// Test: Generics are stripped.
818    #[test]
819    fn test_slugify_generics() {
820        assert_eq!(AnchorUtils::slugify_anchor("HashMap<K, V>"), "hashmap");
821        assert_eq!(AnchorUtils::slugify_anchor("Vec<T>"), "vec");
822        assert_eq!(AnchorUtils::slugify_anchor("Into<T>"), "into");
823        assert_eq!(AnchorUtils::slugify_anchor("Result<T, E>"), "result");
824    }
825
826    /// Test: Nested generics are handled.
827    #[test]
828    fn test_slugify_nested_generics() {
829        assert_eq!(AnchorUtils::slugify_anchor("Option<Vec<T>>"), "option");
830        assert_eq!(AnchorUtils::slugify_anchor("Box<dyn Fn()>"), "box");
831    }
832
833    /// Test: Underscores become hyphens.
834    #[test]
835    fn test_slugify_underscores() {
836        assert_eq!(AnchorUtils::slugify_anchor("my_function"), "my-function");
837        assert_eq!(
838            AnchorUtils::slugify_anchor("some_long_name"),
839            "some-long-name"
840        );
841    }
842
843    /// Test: Spaces become hyphens.
844    #[test]
845    fn test_slugify_spaces() {
846        assert_eq!(AnchorUtils::slugify_anchor("Some Name"), "some-name");
847    }
848
849    /// Test: Backticks are stripped.
850    #[test]
851    fn test_slugify_backticks() {
852        assert_eq!(AnchorUtils::slugify_anchor("`HashMap`"), "hashmap");
853        assert_eq!(AnchorUtils::slugify_anchor("`Vec<T>`"), "vec");
854    }
855
856    /// Test: Punctuation is stripped.
857    #[test]
858    fn test_slugify_punctuation() {
859        assert_eq!(AnchorUtils::slugify_anchor("item!"), "item");
860        assert_eq!(AnchorUtils::slugify_anchor("print!"), "print");
861    }
862
863    /// Test: Consecutive hyphens are collapsed.
864    #[test]
865    fn test_slugify_consecutive_hyphens() {
866        assert_eq!(AnchorUtils::slugify_anchor("a__b"), "a-b");
867        assert_eq!(AnchorUtils::slugify_anchor("a - b"), "a-b");
868    }
869
870    /// Test: Unicode characters are preserved and lowercased.
871    #[test]
872    fn test_slugify_unicode() {
873        // German
874        assert_eq!(AnchorUtils::slugify_anchor("Größe"), "größe");
875        // French
876        assert_eq!(AnchorUtils::slugify_anchor("café"), "café");
877        // Mixed unicode with underscores
878        assert_eq!(AnchorUtils::slugify_anchor("naïve_string"), "naïve-string");
879    }
880
881    /// Test: Unicode normalization (composed vs decomposed).
882    #[test]
883    fn test_slugify_unicode_normalization() {
884        // "é" can be represented as:
885        // - U+00E9 (LATIN SMALL LETTER E WITH ACUTE) - composed
886        // - U+0065 U+0301 (e + COMBINING ACUTE ACCENT) - decomposed
887        let composed = "caf\u{00E9}"; // café with composed é
888        let decomposed = "cafe\u{0301}"; // café with decomposed é
889
890        // Both should produce the same result after NFC normalization
891        assert_eq!(
892            AnchorUtils::slugify_anchor(composed),
893            AnchorUtils::slugify_anchor(decomposed)
894        );
895        assert_eq!(AnchorUtils::slugify_anchor(composed), "café");
896    }
897
898    /// Test: Unicode uppercase conversion (beyond ASCII).
899    #[test]
900    fn test_slugify_unicode_uppercase() {
901        // German sharp S: ẞ (U+1E9E) lowercases to ß (U+00DF)
902        assert_eq!(AnchorUtils::slugify_anchor("GROẞE"), "große");
903
904        // Greek
905        assert_eq!(AnchorUtils::slugify_anchor("ΩMEGA"), "ωmega");
906    }
907
908    // =========================================================================
909    // method_anchor tests
910    // =========================================================================
911
912    /// Test: Basic method anchor generation.
913    #[test]
914    fn test_method_anchor_basic() {
915        assert_eq!(
916            AnchorUtils::method_anchor("Parser", "parse"),
917            "parser-parse"
918        );
919        assert_eq!(AnchorUtils::method_anchor("HashMap", "new"), "hashmap-new");
920        assert_eq!(AnchorUtils::method_anchor("Vec", "push"), "vec-push");
921    }
922
923    /// Test: Method anchor with generics in type name.
924    #[test]
925    fn test_method_anchor_with_generics() {
926        assert_eq!(AnchorUtils::method_anchor("Vec<T>", "push"), "vec-push");
927        assert_eq!(
928            AnchorUtils::method_anchor("HashMap<K, V>", "insert"),
929            "hashmap-insert"
930        );
931        assert_eq!(
932            AnchorUtils::method_anchor("Option<T>", "unwrap"),
933            "option-unwrap"
934        );
935    }
936
937    /// Test: Method anchor with underscores.
938    #[test]
939    fn test_method_anchor_underscores() {
940        assert_eq!(
941            AnchorUtils::method_anchor("MyType", "my_method"),
942            "mytype-my-method"
943        );
944        assert_eq!(
945            AnchorUtils::method_anchor("some_type", "do_thing"),
946            "some-type-do-thing"
947        );
948    }
949
950    /// Test: Method anchor preserves case normalization.
951    #[test]
952    fn test_method_anchor_case() {
953        assert_eq!(
954            AnchorUtils::method_anchor("MyStruct", "DoSomething"),
955            "mystruct-dosomething"
956        );
957    }
958}