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}