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