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}