cargo_docs_md/generator/doc_links.rs
1//! Intra-doc link processing for documentation generation.
2//!
3//! This module provides [`DocLinkProcessor`] which transforms rustdoc
4//! intra-doc link syntax into proper markdown links.
5//!
6//! # Processing Pipeline
7//! The processor applies transformations in this order:
8//! 1. Strip markdown reference definitions
9//! 2. Unhide rustdoc hidden lines in code blocks
10//! 3. Process reference-style links `[text][`ref`]`
11//! 4. Process path reference links `[text][crate::path]`
12//! 5. Process method links `[Type::method]`
13//! 6. Process backtick links `[`Name`]`
14//! 7. Process plain links `[name]`
15//! 8. Convert HTML-style rustdoc links
16//! 9. Clean up blank lines
17//!
18//! Links inside code blocks are protected from transformation.
19
20use std::collections::HashMap;
21use std::fmt::Write;
22use std::sync::LazyLock;
23
24use regex::Regex;
25use rustdoc_types::{Crate, Id, ItemKind};
26
27use crate::linker::{AnchorUtils, LinkRegistry};
28use crate::utils::PathUtils;
29
30// =============================================================================
31// Static Regex Patterns (compiled once, reused everywhere)
32// =============================================================================
33
34/// Regex for HTML-style rustdoc links.
35/// Matches: `(struct.Name.html)` or `(enum.Name.html#method.foo)`
36static HTML_LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
37 Regex::new(concat!(
38 r"\((struct|enum|trait|fn|type|macro|constant|mod)\.",
39 r"([A-Za-z_][A-Za-z0-9_]*)\.html",
40 r"(?:#([a-z]+)\.([A-Za-z_][A-Za-z0-9_]*))?\)",
41 ))
42 .unwrap()
43});
44
45/// Regex for path-style reference links.
46///
47/// Matches: `[display][crate::path::Item]`
48///
49/// Used for rustdoc's reference-style intra-doc links where the display text
50/// differs from the path reference.
51///
52/// # Capture Groups
53/// - Group 1: Display text (anything except `]`)
54/// - Group 2: Rust path with `::` separators (e.g., `crate::module::Item`)
55///
56/// # Pattern Breakdown
57/// ```text
58/// \[([^\]]+)\] # [display text] - capture non-] chars
59/// \[ # Opening bracket for reference
60/// ([a-zA-Z_][a-zA-Z0-9_]* # First path segment (valid Rust identifier)
61/// (?:::[a-zA-Z_][a-zA-Z0-9_]*)+ # One or more ::segment pairs (requires at least one ::)
62/// )\] # Close capture and bracket
63/// ```
64///
65/// # Note
66/// The pattern requires at least one `::` separator, so it won't match
67/// single identifiers like `[text][Name]`.
68static PATH_REF_LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
69 Regex::new(r"\[([^\]]+)\]\[([a-zA-Z_][a-zA-Z0-9_]*(?:::[a-zA-Z_][a-zA-Z0-9_]*)+)\]").unwrap()
70});
71
72/// Regex for backtick code links.
73///
74/// Matches: `` [`Name`] `` (the most common intra-doc link format)
75///
76/// This is the primary pattern for rustdoc intra-doc links. The backticks
77/// indicate the link should be rendered as inline code.
78///
79/// # Capture Groups
80/// - Group 1: The link text inside backticks (e.g., `Name`, `path::Item`)
81///
82/// # Pattern Breakdown
83/// ```text
84/// \[` # Literal "[`" - opening bracket and backtick
85/// ([^`]+) # Capture: one or more non-backtick characters
86/// `\] # Literal "`]" - closing backtick and bracket
87/// ```
88///
89/// # Processing Note
90/// The code checks if the match is followed by `(` to avoid double-processing
91/// already-converted markdown links like `` [`Name`](url) ``.
92static BACKTICK_LINK_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[`([^`]+)`\]").unwrap());
93
94/// Regex for reference-style links with backticks.
95///
96/// Matches: `` [display text][`ref`] ``
97///
98/// This pattern handles rustdoc reference-style links where custom display
99/// text links to a backtick-wrapped reference.
100///
101/// # Capture Groups
102/// - Group 1: Display text (what the user sees)
103/// - Group 2: Reference text inside backticks (the actual link target)
104///
105/// # Pattern Breakdown
106/// ```text
107/// \[([^\]]+)\] # [display text] - capture anything except ]
108/// \[` # Opening "[`" for the reference
109/// ([^`]+) # Capture: reference name (non-backtick chars)
110/// `\] # Closing "`]"
111/// ```
112///
113/// # Example
114/// `` [custom text][`HashMap`] `` renders as "custom text" linking to `HashMap`.
115static REFERENCE_LINK_RE: LazyLock<Regex> =
116 LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\[`([^`]+)`\]").unwrap());
117
118/// Regex for markdown reference definitions.
119///
120/// Matches: `` [`Name`]: path::to::item `` at line start
121///
122/// These are markdown reference definition lines that rustdoc uses internally.
123/// We strip these from output since intra-doc links are resolved directly.
124///
125/// # Pattern Breakdown
126/// ```text
127/// (?m) # Multi-line mode: ^ and $ match line boundaries
128/// ^ # Start of line
129/// \s* # Optional leading whitespace
130/// \[`[^`]+`\] # Backtick link syntax (not captured)
131/// : # Literal colon separator
132/// \s* # Optional whitespace after colon
133/// \S+ # The target path (non-whitespace chars)
134/// \s* # Optional trailing whitespace
135/// $ # End of line
136/// ```
137///
138/// # Note
139/// This pattern doesn't capture groups because it's used with `replace_all`
140/// to remove entire lines.
141///
142/// Matches various reference definition formats:
143/// - `[`Foo`]: crate::Foo` (backtick style)
144/// - `[name]: crate::path` (plain style)
145/// - `[name](#anchor): crate::path` (with anchor)
146static REFERENCE_DEF_RE: LazyLock<Regex> =
147 LazyLock::new(|| Regex::new(r"(?m)^\s*\[[^\]]+\](?:\([^)]*\))?:\s*\S+\s*$").unwrap());
148
149/// Regex for plain identifier links.
150///
151/// Matches: `[name]` where name is a valid Rust identifier
152///
153/// This handles the simplest intra-doc link format without backticks.
154/// Used less frequently than backtick links but still valid rustdoc syntax.
155///
156/// # Capture Groups
157/// - Group 1: The identifier name
158///
159/// # Pattern Breakdown
160/// ```text
161/// \[ # Opening bracket
162/// ([a-zA-Z_] # Capture start: letter or underscore (Rust identifier rules)
163/// [a-zA-Z0-9_]*) # Followed by alphanumeric or underscore
164/// \] # Closing bracket
165/// ```
166///
167/// # Processing Note
168/// The code checks if the match is followed by `(` or `[` to avoid
169/// false positives on existing markdown links or reference-style links.
170/// Also only processes if the identifier exists in `item_links`.
171static PLAIN_LINK_RE: LazyLock<Regex> =
172 LazyLock::new(|| Regex::new(r"\[([a-zA-Z_][a-zA-Z0-9_]*)\]").unwrap());
173
174/// Regex for method/associated item links.
175///
176/// Matches: `` [`Type::method`] `` or `` [`mod::Type::CONST`] ``
177///
178/// Handles links to methods, associated functions, constants, and other
179/// items accessed via `::` path notation. This includes both type-level
180/// paths (`Type::method`) and module-level paths (`mod::Type::CONST`).
181///
182/// # Capture Groups
183/// - Group 1: The full path including `::` separators
184///
185/// # Pattern Breakdown
186/// ```text
187/// \[` # Opening "[`"
188/// ( # Start capture group
189/// [A-Za-z_][A-Za-z0-9_]* # First segment (Rust identifier)
190/// (?:::[A-Za-z_][A-Za-z0-9_]*)+ # One or more ::segment pairs
191/// ) # End capture group
192/// `\] # Closing "`]"
193/// ```
194///
195/// # Examples Matched
196/// - `` [`HashMap::new`] `` - associated function
197/// - `` [`Option::Some`] `` - enum variant
198/// - `` [`Iterator::next`] `` - trait method
199/// - `` [`std::vec::Vec`] `` - fully qualified path
200///
201/// # Processing Note
202/// The last segment after `::` is used as the anchor (lowercased).
203/// The type path before the last `::` is used to find the target file.
204static METHOD_LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
205 Regex::new(r"\[`([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)+)`\]").unwrap()
206});
207
208// =============================================================================
209// Code Block Tracking
210// =============================================================================
211
212/// Classification of a line during code block processing.
213///
214/// Used by [`CodeBlockTracker`] to provide rich information about each line,
215/// enabling callers to handle fences and content appropriately.
216///
217/// # Processing Flow
218///
219/// ```text
220/// Input Line │ State Before │ Returns │ State After
221/// ────────────────────┼──────────────┼─────────────────────┼─────────────
222/// "```" │ Outside │ OpeningFence(bare) │ Inside(```)
223/// "```rust" │ Outside │ OpeningFence(!bare) │ Inside(```)
224/// "let x = 1;" │ Inside(```) │ CodeContent │ Inside(```)
225/// "```" │ Inside(```) │ ClosingFence │ Outside
226/// "regular text" │ Outside │ Text │ Outside
227/// "~~~" │ Inside(```) │ CodeContent │ Inside(```) ← mismatched!
228/// ```
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230enum LineKind {
231 /// Opening code fence (``` or ~~~).
232 /// `bare` is true if the fence has no language specifier (exactly "```" or "~~~").
233 OpeningFence { bare: bool },
234
235 /// Closing code fence matching the opening fence.
236 ClosingFence,
237
238 /// Content inside a code block (not a fence line).
239 CodeContent,
240
241 /// Regular text outside any code block.
242 Text,
243}
244
245/// Tracks code block state while processing documentation line by line.
246///
247/// This provides a clean state machine for fence tracking that both
248/// `unhide_code_lines` and `process_links_protected` can use, avoiding
249/// duplicated inline fence detection logic.
250///
251/// # Example
252///
253/// ```text
254/// let mut tracker = CodeBlockTracker::new();
255///
256/// for line in docs.lines() {
257/// match tracker.classify(line) {
258/// LineKind::OpeningFence { bare } => { /* handle opening */ }
259/// LineKind::CodeContent => { /* process hidden lines, etc. */ }
260/// LineKind::ClosingFence => { /* output as-is */ }
261/// LineKind::Text => { /* process links */ }
262/// }
263/// }
264/// ```
265///
266/// # Fence Matching
267///
268/// The tracker correctly handles mismatched fences:
269/// - `~~~` inside a ```` ``` ```` block is treated as content, not a closing fence
270/// - Only the same fence style closes a block
271struct CodeBlockTracker {
272 /// Current fence string if inside a code block (`Some("```")` or `Some("~~~")`).
273 /// `None` when outside any code block.
274 fence: Option<&'static str>,
275}
276
277impl CodeBlockTracker {
278 /// Create a new tracker starting outside any code block.
279 const fn new() -> Self {
280 Self { fence: None }
281 }
282
283 /// Classify a line and update the tracker's state.
284 ///
285 /// This method both returns the line's classification AND updates
286 /// the tracker's state. Call once per line in order.
287 ///
288 /// # State Transitions
289 ///
290 /// ```text
291 /// ┌─────────┐ "```" or "~~~" ┌──────────┐
292 /// │ Outside │ ───────────────→ │ Inside │
293 /// │ │ ←─────────────── │ │
294 /// └─────────┘ matching fence └──────────┘
295 /// ```
296 fn classify(&mut self, line: &str) -> LineKind {
297 let trimmed = line.trim_start();
298
299 // Detect fence markers (handles indented fences like " ```")
300 let detected_fence = if trimmed.starts_with("```") {
301 Some("```")
302 } else if trimmed.starts_with("~~~") {
303 Some("~~~")
304 } else {
305 None
306 };
307
308 match (self.fence, detected_fence) {
309 // Inside code block, found matching closing fence
310 // Example: Inside "```" block, line is "```" → close the block
311 (Some(open), Some(_)) if trimmed.starts_with(open) => {
312 self.fence = None;
313 LineKind::ClosingFence
314 },
315
316 // Inside code block, line is regular content (or mismatched fence)
317 // Example: Inside "```" block, line is "~~~" → just content
318 (Some(_), _) => LineKind::CodeContent,
319
320 // Outside code block, found opening fence
321 // Example: line is "```rust" → enter code block
322 (None, Some(f)) => {
323 self.fence = Some(f);
324 // Bare fence = exactly "```" or "~~~" with no language
325 let bare = trimmed == "```" || trimmed == "~~~";
326 LineKind::OpeningFence { bare }
327 },
328
329 // Outside code block, regular text line
330 (None, None) => LineKind::Text,
331 }
332 }
333}
334
335// =============================================================================
336// Standalone Functions
337// =============================================================================
338
339// =============================================================================
340// DocLinkProcessor
341// =============================================================================
342
343/// Processes doc comments to resolve intra-doc links to markdown links.
344///
345/// Rustdoc JSON includes a `links` field on each Item that maps intra-doc
346/// link text to item IDs. This processor uses that map along with the
347/// `LinkRegistry` to convert these to relative markdown links.
348///
349/// # Supported Patterns
350///
351/// - `` [`Name`] `` - Backtick code links (most common)
352/// - `` [`path::to::Item`] `` - Qualified path links
353/// - `` [`Type::method`] `` - Method/associated item links
354/// - `[name]` - Plain identifier links
355/// - `[text][`ref`]` - Reference-style links
356/// - `[text][crate::path]` - Path reference links
357///
358/// # External Crate Links
359///
360/// Items from external crates are linked to docs.rs when possible.
361///
362/// # Code Block Protection
363///
364/// Links inside fenced code blocks are not processed.
365pub struct DocLinkProcessor<'a> {
366 /// The crate being documented (for looking up items).
367 krate: &'a Crate,
368
369 /// Registry mapping IDs to file paths.
370 link_registry: &'a LinkRegistry,
371
372 /// The current file path (for relative link calculation).
373 current_file: &'a str,
374
375 /// Index mapping item names to their IDs for fast lookup.
376 /// Built from `krate.paths` at construction time.
377 path_name_index: HashMap<&'a str, Vec<Id>>,
378}
379
380impl<'a> DocLinkProcessor<'a> {
381 /// Create a new processor with a pre-built path name index.
382 ///
383 /// This is the preferred constructor when the index has already been built
384 /// (e.g., in `GeneratorContext`), avoiding redundant index construction.
385 #[must_use]
386 pub fn with_index(
387 krate: &'a Crate,
388 link_registry: &'a LinkRegistry,
389 current_file: &'a str,
390 path_name_index: &HashMap<&'a str, Vec<Id>>,
391 ) -> Self {
392 Self {
393 krate,
394 link_registry,
395 current_file,
396 path_name_index: path_name_index.clone(),
397 }
398 }
399
400 /// Create a new processor for the given context.
401 ///
402 /// Builds the path name index internally. Prefer [`Self::with_index`] when
403 /// the index has already been built to avoid redundant computation.
404 #[must_use]
405 pub fn new(krate: &'a Crate, link_registry: &'a LinkRegistry, current_file: &'a str) -> Self {
406 // Build path name index for O(1) lookups
407 let mut path_name_index: HashMap<&'a str, Vec<Id>> = HashMap::new();
408 for (id, path_info) in &krate.paths {
409 if let Some(name) = path_info.path.last() {
410 path_name_index.entry(name.as_str()).or_default().push(*id);
411 }
412 }
413
414 // Sort each Vec by full path for deterministic resolution order
415 // Using direct Vec<String> comparison (lexicographic) instead of joining
416 for ids in path_name_index.values_mut() {
417 ids.sort_by(|a, b| {
418 let path_a = krate.paths.get(a).map(|p| &p.path);
419 let path_b = krate.paths.get(b).map(|p| &p.path);
420 path_a.cmp(&path_b)
421 });
422 }
423
424 Self {
425 krate,
426 link_registry,
427 current_file,
428 path_name_index,
429 }
430 }
431
432 /// Process a doc string and resolve all intra-doc links.
433 ///
434 /// Uses the item's `links` map to resolve link text to IDs,
435 /// then uses `LinkRegistry` to convert IDs to relative paths.
436 #[must_use]
437 pub fn process(&self, docs: &str, item_links: &HashMap<String, Id>) -> String {
438 // Step 1: Strip reference definitions first
439 let stripped = DocLinkUtils::strip_reference_definitions(docs);
440
441 // Step 2: Unhide rustdoc hidden lines in code blocks and add `rust` to bare fences
442 let unhidden = DocLinkUtils::unhide_code_lines(&stripped);
443
444 // Step 3: Process all link types (with code block protection)
445 let processed = self.process_links_protected(&unhidden, item_links);
446
447 // Step 4: Clean up blank lines
448 Self::clean_blank_lines(&processed)
449 }
450
451 /// Process links while protecting code block contents.
452 ///
453 /// Uses [`CodeBlockTracker`] to identify which lines are inside code blocks
454 /// (and should be left unchanged) vs regular text (which needs link processing).
455 fn process_links_protected(&self, docs: &str, item_links: &HashMap<String, Id>) -> String {
456 let mut result = String::with_capacity(docs.len());
457 let mut tracker = CodeBlockTracker::new();
458 let mut current_pos = 0;
459
460 for line in docs.lines() {
461 let line_end = current_pos + line.len();
462
463 // Classify line and update tracker state
464 match tracker.classify(line) {
465 // Opening/closing fences and code content: pass through unchanged
466 LineKind::OpeningFence { .. } | LineKind::ClosingFence | LineKind::CodeContent => {
467 _ = write!(result, "{line}");
468 },
469
470 // Text outside code blocks: process links
471 LineKind::Text => {
472 let processed = self.process_line(line, item_links);
473 _ = write!(result, "{processed}");
474 },
475 }
476
477 // Add newline if not at end of input
478 current_pos = line_end;
479 if current_pos < docs.len() {
480 _ = writeln!(result);
481 current_pos += 1; // Skip the newline character
482 }
483 }
484
485 result
486 }
487
488 /// Process a single line for all link types.
489 fn process_line(&self, line: &str, item_links: &HashMap<String, Id>) -> String {
490 // Preserve reference definition lines unchanged (they're needed for markdown parsers)
491 // FIX: Previously returned empty string which dropped these lines entirely,
492 // breaking all reference-style links like [text][`Foo`]
493 if line.trim_start().starts_with("[`") && line.contains("]:") {
494 return line.to_string();
495 }
496
497 // Process in order of specificity (most specific patterns first)
498 let s = self.process_reference_links(line, item_links);
499 let s = self.process_path_reference_links(&s, item_links);
500 let s = self.process_method_links(&s, item_links);
501 let s = self.process_backtick_links(&s, item_links);
502 let s = self.process_plain_links(&s, item_links);
503
504 self.process_html_links_with_context(&s, item_links)
505 }
506
507 /// Process reference-style links `[display text][`Span`]`.
508 fn process_reference_links(&self, text: &str, item_links: &HashMap<String, Id>) -> String {
509 DocLinkUtils::replace_with_regex(text, &REFERENCE_LINK_RE, |caps| {
510 let display_text = &caps[1];
511 let ref_key = &caps[2];
512
513 self.resolve_to_url(ref_key, item_links).map_or_else(
514 || caps[0].to_string(),
515 |url| format!("[{display_text}]({url})"),
516 )
517 })
518 }
519
520 /// Process path reference links `[text][crate::path::Item]`.
521 fn process_path_reference_links(&self, text: &str, item_links: &HashMap<String, Id>) -> String {
522 DocLinkUtils::replace_with_regex(text, &PATH_REF_LINK_RE, |caps| {
523 let display_text = &caps[1];
524 let rust_path = &caps[2];
525
526 self.resolve_to_url(rust_path, item_links).map_or_else(
527 // Can't resolve - keep as inline code without broken anchor
528 || {
529 // Don't double-wrap in backticks
530 if display_text.starts_with('`') && display_text.ends_with('`') {
531 display_text.to_string()
532 } else {
533 format!("`{display_text}`")
534 }
535 },
536 |url| format!("[{display_text}]({url})"),
537 )
538 })
539 }
540
541 /// Process method links `[``Type::method``]`.
542 fn process_method_links(&self, text: &str, item_links: &HashMap<String, Id>) -> String {
543 DocLinkUtils::replace_with_regex_checked(text, &METHOD_LINK_RE, |caps, rest| {
544 // Skip if already a markdown link
545 if rest.starts_with('(') {
546 return caps[0].to_string();
547 }
548
549 let full_path = &caps[1];
550 if let Some(last_sep) = full_path.rfind("::") {
551 let type_part = &full_path[..last_sep];
552 let method_part = &full_path[last_sep + 2..];
553
554 if let Some(link) = self.resolve_method_link(type_part, method_part, item_links) {
555 return link;
556 }
557 }
558 caps[0].to_string()
559 })
560 }
561
562 /// Process backtick links `[`Name`]`.
563 fn process_backtick_links(&self, text: &str, item_links: &HashMap<String, Id>) -> String {
564 DocLinkUtils::replace_with_regex_checked(text, &BACKTICK_LINK_RE, |caps, rest| {
565 // Skip if already a markdown link
566 if rest.starts_with('(') {
567 return caps[0].to_string();
568 }
569
570 let link_text = &caps[1];
571 self.resolve_link(link_text, item_links)
572 })
573 }
574
575 /// Process plain links `[name]`.
576 fn process_plain_links(&self, text: &str, item_links: &HashMap<String, Id>) -> String {
577 DocLinkUtils::replace_with_regex_checked(text, &PLAIN_LINK_RE, |caps, rest| {
578 // Skip if already a markdown link
579 if matches!(rest.chars().next(), Some('(' | '[')) {
580 return caps[0].to_string();
581 }
582
583 let link_text = &caps[1];
584
585 // Only process if it's in item_links (avoid false positives)
586 if let Some(id) = item_links.get(link_text)
587 && let Some(md_link) = self.create_link_for_id(*id, link_text)
588 {
589 return md_link;
590 }
591 caps[0].to_string()
592 })
593 }
594
595 /// Process HTML-style rustdoc links with context awareness.
596 ///
597 /// Instead of blindly converting all HTML links to local anchors,
598 /// this method checks if the item actually exists on the current page.
599 /// If not, it tries to resolve to docs.rs or removes the broken link.
600 ///
601 /// For method links (e.g., `struct.Foo.html#method.bar`), creates a
602 /// method anchor like `#foo-bar` for deep linking.
603 fn process_html_links_with_context(
604 &self,
605 text: &str,
606 item_links: &HashMap<String, Id>,
607 ) -> String {
608 DocLinkUtils::replace_with_regex(text, &HTML_LINK_RE, |caps| {
609 let item_kind = &caps[1]; // struct, enum, trait, etc.
610 let item_name = &caps[2];
611
612 // If there's a method/variant anchor part, create a method anchor
613 if let Some(method_match) = caps.get(4) {
614 let method_name = method_match.as_str();
615
616 // Try to resolve the type first
617 if let Some(url) = self.resolve_html_link_to_url(item_name, item_kind, item_links) {
618 let anchor = AnchorUtils::method_anchor(item_name, method_name);
619
620 if url.is_empty() {
621 // Item on current page - just use anchor
622 return format!("(#{anchor})");
623 }
624
625 // Item on another page - append anchor to URL
626 return format!("({url}#{anchor})");
627 }
628
629 // Can't resolve type - use simple method anchor (assume same page)
630 let anchor = AnchorUtils::method_anchor(item_name, method_name);
631 return format!("(#{anchor})");
632 }
633
634 // Try to find this item in our link resolution
635 if let Some(url) = self.resolve_html_link_to_url(item_name, item_kind, item_links) {
636 return format!("({url})");
637 }
638
639 // Fallback: remove the link part entirely (keep just the display text)
640 // This is better than creating a broken #anchor
641 String::new()
642 })
643 }
644
645 /// Try to resolve an HTML-style link to a proper URL.
646 ///
647 /// Returns a URL if the item can be resolved (either locally or to docs.rs),
648 /// or None if the item cannot be found.
649 fn resolve_html_link_to_url(
650 &self,
651 item_name: &str,
652 item_kind: &str,
653 item_links: &HashMap<String, Id>,
654 ) -> Option<String> {
655 // Strategy 1: Check if item is in item_links
656 if let Some(id) = item_links.get(item_name) {
657 // Check if it's on the current page
658 if let Some(path) = self.link_registry.get_path(*id) {
659 if path == self.current_file {
660 // Only create anchor if item has a heading
661 if let Some(path_info) = self.krate.paths.get(id)
662 && AnchorUtils::item_has_anchor(path_info.kind)
663 {
664 // FIX: Use slugify_anchor for consistent anchor generation
665 // "my_type" → "my-type" to match heading anchors
666 return Some(format!("#{}", AnchorUtils::slugify_anchor(item_name)));
667 }
668
669 // Item on page but no anchor - link to page without anchor
670 return Some(String::new());
671 }
672
673 // Item is in another file
674 let relative = LinkRegistry::compute_relative_path(self.current_file, path);
675
676 return Some(relative);
677 }
678
679 // Try docs.rs for external crates
680 if let Some(path_info) = self.krate.paths.get(id)
681 && path_info.crate_id != 0
682 {
683 return Self::get_docs_rs_url(path_info);
684 }
685 }
686
687 // Strategy 2: Search path_name_index for the item name
688 if let Some(ids) = self.path_name_index.get(item_name) {
689 for id in ids {
690 if let Some(path) = self.link_registry.get_path(*id) {
691 if path == self.current_file {
692 // Only create anchor if item has a heading
693 if let Some(path_info) = self.krate.paths.get(id)
694 && AnchorUtils::item_has_anchor(path_info.kind)
695 {
696 // FIX: Use slugify_anchor for consistent anchor generation
697 return Some(format!("#{}", AnchorUtils::slugify_anchor(item_name)));
698 }
699
700 // Item on page but no anchor - link to page without anchor
701 return Some(String::new());
702 }
703
704 let relative = LinkRegistry::compute_relative_path(self.current_file, path);
705
706 return Some(relative);
707 }
708
709 // Try docs.rs
710 if let Some(path_info) = self.krate.paths.get(id)
711 && path_info.crate_id != 0
712 {
713 return Self::get_docs_rs_url(path_info);
714 }
715 }
716 }
717
718 // Strategy 3: Search krate.paths for external items by name
719 // Collect all matches and pick the shortest path (most specific) for determinism
720 let mut matches: Vec<_> = self
721 .krate
722 .paths
723 .values()
724 .filter(|path_info| {
725 path_info.crate_id != 0
726 && path_info.path.last().is_some_and(|name| name == item_name)
727 && Self::kind_matches(item_kind, path_info.kind)
728 })
729 .collect();
730
731 // Sort by full path for deterministic selection
732 // Using direct Vec<String> comparison (lexicographic) instead of joining
733 matches.sort_by(|a, b| a.path.cmp(&b.path));
734
735 matches
736 .first()
737 .and_then(|path_info| Self::get_docs_rs_url(path_info))
738 }
739
740 /// Check if the HTML link kind matches the rustdoc item kind.
741 fn kind_matches(html_kind: &str, item_kind: ItemKind) -> bool {
742 match html_kind {
743 "struct" => item_kind == ItemKind::Struct,
744
745 "enum" => item_kind == ItemKind::Enum,
746
747 "trait" => item_kind == ItemKind::Trait,
748
749 "fn" => item_kind == ItemKind::Function,
750
751 "type" => item_kind == ItemKind::TypeAlias,
752
753 "macro" => item_kind == ItemKind::Macro,
754
755 "constant" => item_kind == ItemKind::Constant,
756
757 "mod" => item_kind == ItemKind::Module,
758
759 _ => false,
760 }
761 }
762
763 /// Clean up multiple consecutive blank lines.
764 fn clean_blank_lines(docs: &str) -> String {
765 let mut result = String::with_capacity(docs.len());
766 let mut prev_blank = false;
767
768 for line in docs.lines() {
769 let is_blank = line.trim().is_empty();
770 if is_blank && prev_blank {
771 continue;
772 }
773
774 if !result.is_empty() {
775 _ = writeln!(result);
776 }
777
778 _ = write!(result, "{line}");
779 prev_blank = is_blank;
780 }
781
782 result.trim_end().to_string()
783 }
784
785 // =========================================================================
786 // Resolution Methods
787 // =========================================================================
788 //
789 // # Link Resolution Strategy
790 //
791 // `Item.links: HashMap<String, Id>` is rustdoc's pre-resolved intra-doc link map.
792 // It maps link text to item IDs for all `[`foo`]` style links in the doc comments.
793 // We use this as our primary source (Strategy 1 and 2), falling back to the
794 // path_name_index only when item_links doesn't contain the reference.
795 //
796 // Strategy order (short-circuit on first success):
797 // 1. Exact match in item_links - rustdoc already resolved this link
798 // 2. Short name match in item_links - handle qualified vs unqualified references
799 // 3. Path name index lookup - fallback for cross-references not in item_links
800
801 /// Generic 3-strategy resolution with per-strategy display names.
802 ///
803 /// Unifies the resolution logic used by `resolve_to_url` and `resolve_link`.
804 /// The resolver closure receives both the `Id` and the appropriate display name
805 /// for that strategy:
806 /// - Strategy 1 (exact match): uses original `link_text` (preserves qualified paths)
807 /// - Strategy 2 & 3 (fuzzy matches): uses `short_name`
808 ///
809 /// # Type Parameters
810 ///
811 /// * `T` - The result type (e.g., `String` for URLs or markdown links)
812 ///
813 /// # Arguments
814 ///
815 /// * `link_text` - Original link text from documentation
816 /// * `item_links` - Pre-resolved links from rustdoc
817 /// * `resolver` - Closure that takes `(Id, display_name)` and returns `Option<T>`
818 fn resolve_with_strategies<T, F>(
819 &self,
820 link_text: &str,
821 item_links: &HashMap<String, Id>,
822 resolver: F,
823 ) -> Option<T>
824 where
825 F: Fn(Id, &str) -> Option<T>,
826 {
827 let short = PathUtils::short_name(link_text);
828
829 // Strategy 1: Exact match - preserve original link_text as display name
830 if let Some(&id) = item_links.get(link_text)
831 && let Some(result) = resolver(id, link_text)
832 {
833 return Some(result);
834 }
835
836 // Strategy 2: Short name match in item_links - use short name
837 for (key, &id) in item_links {
838 if PathUtils::short_name(key) == short
839 && let Some(result) = resolver(id, short)
840 {
841 return Some(result);
842 }
843 }
844
845 // Strategy 3: Path name index fallback - use short name
846 if let Some(ids) = self.path_name_index.get(short) {
847 for &id in ids {
848 if let Some(result) = resolver(id, short) {
849 return Some(result);
850 }
851 }
852 }
853
854 None
855 }
856
857 /// Resolve a link reference to a URL.
858 ///
859 /// Uses the generic 3-strategy resolver. Display name is ignored since
860 /// we only need the URL.
861 fn resolve_to_url(&self, link_text: &str, item_links: &HashMap<String, Id>) -> Option<String> {
862 self.resolve_with_strategies(link_text, item_links, |id, _display| {
863 self.get_url_for_id(id)
864 })
865 }
866
867 /// Get the URL for an ID (local or docs.rs).
868 fn get_url_for_id(&self, id: Id) -> Option<String> {
869 // Try local first
870 if let Some(path) = self.link_registry.get_path(id) {
871 // Check if target is on the same page - use anchor instead of relative path
872 if path == self.current_file {
873 // Get the item name for anchor generation
874 if let Some(name) = self.link_registry.get_name(id) {
875 return Some(format!("#{}", AnchorUtils::slugify_anchor(name)));
876 }
877 }
878
879 let relative = LinkRegistry::compute_relative_path(self.current_file, path);
880
881 return Some(relative);
882 }
883
884 // Try docs.rs for external crates
885 if let Some(path_info) = self.krate.paths.get(&id)
886 && path_info.crate_id != 0
887 {
888 return Self::get_docs_rs_url(path_info);
889 }
890
891 None
892 }
893
894 /// Get docs.rs URL for an external crate item.
895 fn get_docs_rs_url(path_info: &rustdoc_types::ItemSummary) -> Option<String> {
896 let path = &path_info.path;
897 if path.is_empty() {
898 return None;
899 }
900
901 let crate_name = &path[0];
902
903 // Handle module URLs specially
904 if path_info.kind == ItemKind::Module {
905 if path.len() == 1 {
906 return Some(format!("https://docs.rs/{crate_name}/latest/{crate_name}/"));
907 }
908
909 let module_path = path[1..].join("/");
910
911 return Some(format!(
912 "https://docs.rs/{crate_name}/latest/{crate_name}/{module_path}/index.html"
913 ));
914 }
915
916 let item_path = path[1..].join("/");
917 let type_prefix = match path_info.kind {
918 ItemKind::Struct => "struct",
919
920 ItemKind::Enum => "enum",
921
922 ItemKind::Trait => "trait",
923
924 ItemKind::Function => "fn",
925
926 ItemKind::Constant => "constant",
927
928 ItemKind::TypeAlias => "type",
929
930 ItemKind::Macro => "macro",
931
932 _ => "index",
933 };
934
935 let item_name = path.last().unwrap_or(crate_name);
936
937 if item_path.is_empty() {
938 Some(format!("https://docs.rs/{crate_name}/latest/{crate_name}/"))
939 } else {
940 // Remove last segment from path for the directory
941 let dir_path = if path.len() > 2 {
942 path[1..path.len() - 1].join("/")
943 } else {
944 String::new()
945 };
946
947 if dir_path.is_empty() {
948 Some(format!(
949 "https://docs.rs/{crate_name}/latest/{crate_name}/{type_prefix}.{item_name}.html"
950 ))
951 } else {
952 Some(format!(
953 "https://docs.rs/{crate_name}/latest/{crate_name}/{dir_path}/{type_prefix}.{item_name}.html"
954 ))
955 }
956 }
957 }
958
959 /// Resolve a method link to a markdown link with method anchor.
960 ///
961 /// Links to the type's page with a method anchor for deep linking
962 /// (e.g., `#hashmap-new` for `HashMap::new`).
963 fn resolve_method_link(
964 &self,
965 type_name: &str,
966 method_name: &str,
967 item_links: &HashMap<String, Id>,
968 ) -> Option<String> {
969 // Try to find the type
970 let short_type = PathUtils::short_name(type_name);
971 let type_id = item_links.get(type_name).or_else(|| {
972 item_links
973 .iter()
974 .find(|(k, _)| PathUtils::short_name(k) == short_type)
975 .map(|(_, id)| id)
976 })?;
977
978 let type_path = self.link_registry.get_path(*type_id)?;
979 let display = format!("{type_name}::{method_name}");
980
981 // Use the short type name for anchor generation
982 let anchor = AnchorUtils::method_anchor(short_type, method_name);
983
984 // Check if type is on the same page - just use anchor
985 if type_path == self.current_file {
986 return Some(format!("[`{display}`](#{anchor})"));
987 }
988
989 let relative = LinkRegistry::compute_relative_path(self.current_file, type_path);
990
991 // Link to the type page with method anchor for deep linking
992 Some(format!("[`{display}`]({relative}#{anchor})"))
993 }
994
995 /// Try to resolve link text to a markdown link.
996 ///
997 /// Uses the generic 3-strategy resolver. Falls back to unresolved link format
998 /// (backtick-wrapped text in brackets) if resolution fails.
999 fn resolve_link(&self, link_text: &str, item_links: &HashMap<String, Id>) -> String {
1000 self.resolve_with_strategies(link_text, item_links, |id, display| {
1001 self.create_link_for_id(id, display)
1002 })
1003 .unwrap_or_else(|| format!("[`{link_text}`]"))
1004 }
1005
1006 /// Create a markdown link for an ID.
1007 fn create_link_for_id(&self, id: Id, display_name: &str) -> Option<String> {
1008 // Try local link (handles same-file anchor links automatically)
1009 if let Some(link) = self.link_registry.create_link(id, self.current_file) {
1010 return Some(link);
1011 }
1012
1013 // Fallback: try to get path and compute relative link
1014 if let Some(path) = self.link_registry.get_path(id) {
1015 let clean_name = PathUtils::short_name(display_name);
1016
1017 // Check if target is on the same page - use anchor instead of relative path
1018 if path == self.current_file {
1019 let anchor = AnchorUtils::slugify_anchor(clean_name);
1020
1021 return Some(format!("[`{clean_name}`](#{anchor})"));
1022 }
1023
1024 let relative = LinkRegistry::compute_relative_path(self.current_file, path);
1025
1026 return Some(format!("[`{clean_name}`]({relative})"));
1027 }
1028
1029 // Try docs.rs for external crates
1030 if let Some(path_info) = self.krate.paths.get(&id)
1031 && path_info.crate_id != 0
1032 {
1033 return Self::create_docs_rs_link(path_info, display_name);
1034 }
1035
1036 None
1037 }
1038
1039 /// Create a docs.rs link for an external crate item.
1040 fn create_docs_rs_link(
1041 path_info: &rustdoc_types::ItemSummary,
1042 display_name: &str,
1043 ) -> Option<String> {
1044 let url = Self::get_docs_rs_url(path_info)?;
1045 let clean_name = PathUtils::short_name(display_name);
1046 Some(format!("[`{clean_name}`]({url})"))
1047 }
1048}
1049
1050/// Utility functions for document links
1051pub struct DocLinkUtils;
1052
1053impl DocLinkUtils {
1054 /// Convert HTML-style rustdoc links to markdown anchors.
1055 ///
1056 /// Transforms links like:
1057 /// - `(enum.NumberPrefix.html)` -> `(#numberprefix)`
1058 /// - `(struct.Foo.html#method.bar)` -> `(#foo-bar)` (type-method anchor)
1059 ///
1060 /// This is useful for multi-crate documentation where the full processor
1061 /// context may not be available.
1062 #[must_use]
1063 pub fn convert_html_links(docs: &str) -> String {
1064 Self::replace_with_regex(docs, &HTML_LINK_RE, |caps| {
1065 let item_name = &caps[2];
1066
1067 // If there's a method/variant anchor part, create a method anchor
1068 caps.get(4).map_or_else(
1069 || format!("(#{})", item_name.to_lowercase()),
1070 |method_match| {
1071 let method_name = method_match.as_str();
1072 let anchor = AnchorUtils::method_anchor(item_name, method_name);
1073
1074 format!("(#{anchor})")
1075 },
1076 )
1077 })
1078 }
1079
1080 /// Strip duplicate title from documentation.
1081 ///
1082 /// Some crate/module docs start with `# title` which duplicates the generated
1083 /// `# Crate 'name'` or `# Module 'name'` heading.
1084 ///
1085 /// # Arguments
1086 ///
1087 /// * `docs` - The documentation string to process
1088 /// * `item_name` - The name of the crate or module being documented
1089 ///
1090 /// # Returns
1091 ///
1092 /// The docs with the leading title removed if it matches the item name,
1093 /// otherwise the original docs unchanged.
1094 #[must_use]
1095 pub fn strip_duplicate_title<'a>(docs: &'a str, item_name: &str) -> &'a str {
1096 let Some(first_line) = docs.lines().next() else {
1097 return docs;
1098 };
1099
1100 let Some(title) = first_line.strip_prefix("# ") else {
1101 return docs;
1102 };
1103
1104 // Normalize the title:
1105 // - Remove backticks (e.g., `clap_builder` -> clap_builder)
1106 // - Replace spaces with underscores (e.g., "Serde JSON" -> "serde_json")
1107 // - Replace hyphens with underscores (e.g., "my-crate" -> "my_crate")
1108 // - Lowercase for comparison
1109 let normalized_title = title
1110 .trim()
1111 .replace('`', "")
1112 .replace(['-', ' '], "_")
1113 .to_lowercase();
1114
1115 let normalized_name = item_name.replace('-', "_").to_lowercase();
1116
1117 if normalized_title == normalized_name {
1118 // Skip the first line and any following blank lines
1119 docs[first_line.len()..].trim_start_matches('\n')
1120 } else {
1121 docs
1122 }
1123 }
1124
1125 /// Strip markdown reference definition lines.
1126 ///
1127 /// Removes lines like `[`Name`]: path::to::item` which are no longer needed
1128 /// after intra-doc links are processed.
1129 pub fn strip_reference_definitions(docs: &str) -> String {
1130 REFERENCE_DEF_RE.replace_all(docs, "").to_string()
1131 }
1132
1133 /// Unhide rustdoc hidden lines in code blocks and add language identifiers.
1134 ///
1135 /// This function performs two transformations on code blocks:
1136 /// 1. Lines starting with `# ` inside code blocks are hidden in rustdoc
1137 /// but compiled. We remove the prefix to show the full example.
1138 /// 2. Bare code fences (` ``` `) are converted to ` ```rust ` since doc
1139 /// examples are Rust code.
1140 ///
1141 /// Uses [`CodeBlockTracker`] to manage fence state.
1142 #[must_use]
1143 pub fn unhide_code_lines(docs: &str) -> String {
1144 let mut result = String::with_capacity(docs.len());
1145 let mut tracker = CodeBlockTracker::new();
1146
1147 for line in docs.lines() {
1148 let trimmed = line.trim_start();
1149 let leading_ws = &line[..line.len() - trimmed.len()];
1150
1151 match tracker.classify(line) {
1152 // Opening fence: add `rust` if bare, otherwise pass through
1153 LineKind::OpeningFence { bare } => {
1154 if bare {
1155 // "```" → "```rust" (preserve indentation)
1156 _ = write!(result, "{leading_ws}{trimmed}rust");
1157 } else {
1158 _ = write!(result, "{line}");
1159 }
1160 },
1161
1162 // Closing fence and regular text: pass through unchanged
1163 LineKind::ClosingFence | LineKind::Text => {
1164 _ = write!(result, "{line}");
1165 },
1166
1167 // Code content: unhide hidden lines
1168 LineKind::CodeContent => {
1169 if trimmed == "#" {
1170 // Lone "#" becomes empty line (newline added below)
1171 } else if let Some(rest) = trimmed.strip_prefix("# ") {
1172 // "# code" becomes "code" (preserve indentation)
1173 _ = write!(result, "{leading_ws}{rest}");
1174 } else {
1175 _ = write!(result, "{line}");
1176 }
1177 },
1178 }
1179
1180 _ = writeln!(result);
1181 }
1182
1183 // Remove trailing newline if original didn't have one
1184 if !docs.ends_with('\n') && result.ends_with('\n') {
1185 result.pop();
1186 }
1187
1188 result
1189 }
1190
1191 /// Convert path-style reference links to inline code.
1192 ///
1193 /// Transforms: `[``ProgressTracker``][crate::style::ProgressTracker]`
1194 /// Into: `` `ProgressTracker` ``
1195 ///
1196 /// Without full link resolution context, we can't create valid anchors,
1197 /// so we preserve the display text as inline code.
1198 #[must_use]
1199 pub fn convert_path_reference_links(docs: &str) -> String {
1200 Self::replace_with_regex(docs, &PATH_REF_LINK_RE, |caps| {
1201 let display_text = &caps[1];
1202
1203 // Don't double-wrap in backticks
1204 if display_text.starts_with('`') && display_text.ends_with('`') {
1205 display_text.to_string()
1206 } else {
1207 format!("`{display_text}`")
1208 }
1209 })
1210 }
1211
1212 /// Replace regex matches using a closure.
1213 fn replace_with_regex<F>(text: &str, re: &Regex, replacer: F) -> String
1214 where
1215 F: Fn(®ex::Captures<'_>) -> String,
1216 {
1217 let mut result = String::with_capacity(text.len());
1218 let mut last_end = 0;
1219
1220 for caps in re.captures_iter(text) {
1221 let m = caps.get(0).unwrap();
1222 _ = write!(result, "{}", &text[last_end..m.start()]);
1223 _ = write!(result, "{}", &replacer(&caps));
1224
1225 last_end = m.end();
1226 }
1227
1228 _ = write!(result, "{}", &text[last_end..]);
1229
1230 result
1231 }
1232
1233 /// Replace regex matches with access to the text after the match.
1234 fn replace_with_regex_checked<F>(text: &str, re: &Regex, replacer: F) -> String
1235 where
1236 F: Fn(®ex::Captures<'_>, &str) -> String,
1237 {
1238 let mut result = String::with_capacity(text.len());
1239 let mut last_end = 0;
1240
1241 for caps in re.captures_iter(text) {
1242 let m = caps.get(0).unwrap();
1243 _ = write!(result, "{}", &text[last_end..m.start()]);
1244
1245 let rest = &text[m.end()..];
1246 _ = write!(result, "{}", &replacer(&caps, rest));
1247
1248 last_end = m.end();
1249 }
1250
1251 _ = write!(result, "{}", &text[last_end..]);
1252
1253 result
1254 }
1255}
1256
1257// =============================================================================
1258// Tests
1259// =============================================================================
1260
1261#[cfg(test)]
1262mod tests {
1263 use super::DocLinkUtils;
1264
1265 #[test]
1266 fn test_convert_html_links() {
1267 // Type-level links get anchors
1268 assert_eq!(
1269 DocLinkUtils::convert_html_links("See (enum.Foo.html) for details"),
1270 "See (#foo) for details"
1271 );
1272 // Method-level links now get method anchors (typename-methodname)
1273 assert_eq!(
1274 DocLinkUtils::convert_html_links("Call (struct.Bar.html#method.new)"),
1275 "Call (#bar-new)"
1276 );
1277 // Verify method anchors work with different types
1278 assert_eq!(
1279 DocLinkUtils::convert_html_links("Use (struct.HashMap.html#method.insert)"),
1280 "Use (#hashmap-insert)"
1281 );
1282 }
1283
1284 #[test]
1285 fn test_strip_duplicate_title() {
1286 let docs = "# my_crate\n\nThis is the description.";
1287 assert_eq!(
1288 DocLinkUtils::strip_duplicate_title(docs, "my_crate"),
1289 "This is the description."
1290 );
1291
1292 // Different title - keep it
1293 let docs2 = "# Introduction\n\nThis is the description.";
1294 assert_eq!(
1295 DocLinkUtils::strip_duplicate_title(docs2, "my_crate"),
1296 docs2
1297 );
1298
1299 // Backticks around title (e.g., # `clap_builder`)
1300 let docs3 = "# `clap_builder`\n\nBuilder implementation.";
1301 assert_eq!(
1302 DocLinkUtils::strip_duplicate_title(docs3, "clap_builder"),
1303 "Builder implementation."
1304 );
1305
1306 // Spaced title (e.g., # Serde JSON -> serde_json)
1307 let docs4 = "# Serde JSON\n\nJSON serialization.";
1308 assert_eq!(
1309 DocLinkUtils::strip_duplicate_title(docs4, "serde_json"),
1310 "JSON serialization."
1311 );
1312
1313 // Hyphenated name
1314 let docs5 = "# my-crate\n\nDescription.";
1315 assert_eq!(
1316 DocLinkUtils::strip_duplicate_title(docs5, "my_crate"),
1317 "Description."
1318 );
1319 }
1320
1321 #[test]
1322 fn test_strip_reference_definitions() {
1323 // Backtick-style reference definitions
1324 let docs = "See [`Foo`] for details.\n\n[`Foo`]: crate::Foo";
1325 let result = DocLinkUtils::strip_reference_definitions(docs);
1326 assert!(result.contains("See [`Foo`]"));
1327 assert!(!result.contains("[`Foo`]: crate::Foo"));
1328
1329 // Plain reference definitions (no backticks)
1330 let docs2 = "Use [value] here.\n\n[value]: crate::value::Value";
1331 let result2 = DocLinkUtils::strip_reference_definitions(docs2);
1332 assert!(result2.contains("Use [value]"));
1333 assert!(!result2.contains("[value]: crate::value::Value"));
1334
1335 // Reference definitions with anchors
1336 let docs3 = "See [from_str](#from-str) docs.\n\n[from_str](#from-str): crate::de::from_str";
1337 let result3 = DocLinkUtils::strip_reference_definitions(docs3);
1338 assert!(result3.contains("See [from_str](#from-str)"));
1339 assert!(!result3.contains("[from_str](#from-str): crate::de::from_str"));
1340
1341 // Multiple reference definitions
1342 let docs4 = "Content.\n\n[a]: path::a\n[b]: path::b\n[`c`]: path::c";
1343 let result4 = DocLinkUtils::strip_reference_definitions(docs4);
1344 assert_eq!(result4.trim(), "Content.");
1345 }
1346
1347 #[test]
1348 fn test_convert_path_reference_links() {
1349 // Path references become inline code (can't create valid anchors without context)
1350 let docs = "[`Tracker`][crate::style::Tracker] is useful";
1351 let result = DocLinkUtils::convert_path_reference_links(docs);
1352 assert_eq!(result, "`Tracker` is useful");
1353 }
1354
1355 #[test]
1356 fn test_unhide_code_lines_strips_hidden_prefix() {
1357 let docs = "```\n# #[cfg(feature = \"test\")]\n# {\nuse foo::bar;\n# }\n```";
1358 let result = DocLinkUtils::unhide_code_lines(docs);
1359 assert_eq!(
1360 result,
1361 "```rust\n#[cfg(feature = \"test\")]\n{\nuse foo::bar;\n}\n```"
1362 );
1363 }
1364
1365 #[test]
1366 fn test_unhide_code_lines_adds_rust_to_bare_fence() {
1367 let docs = "```\nlet x = 1;\n```";
1368 let result = DocLinkUtils::unhide_code_lines(docs);
1369 assert_eq!(result, "```rust\nlet x = 1;\n```");
1370 }
1371
1372 #[test]
1373 fn test_unhide_code_lines_preserves_existing_language() {
1374 let docs = "```python\nprint('hello')\n```";
1375 let result = DocLinkUtils::unhide_code_lines(docs);
1376 assert_eq!(result, "```python\nprint('hello')\n```");
1377 }
1378
1379 #[test]
1380 fn test_unhide_code_lines_handles_tilde_fence() {
1381 let docs = "~~~\ncode\n~~~";
1382 let result = DocLinkUtils::unhide_code_lines(docs);
1383 assert_eq!(result, "~~~rust\ncode\n~~~");
1384 }
1385
1386 #[test]
1387 fn test_unhide_code_lines_lone_hash() {
1388 // A lone # becomes an empty line
1389 let docs = "```\n#\nlet x = 1;\n```";
1390 let result = DocLinkUtils::unhide_code_lines(docs);
1391 assert_eq!(result, "```rust\n\nlet x = 1;\n```");
1392 }
1393
1394 // =========================================================================
1395 // Method anchor tests
1396 // =========================================================================
1397
1398 #[test]
1399 fn test_convert_html_links_method_anchor_format() {
1400 // Method anchors use typename-methodname format
1401 assert_eq!(
1402 DocLinkUtils::convert_html_links("(struct.Vec.html#method.push)"),
1403 "(#vec-push)"
1404 );
1405 assert_eq!(
1406 DocLinkUtils::convert_html_links("(enum.Option.html#method.unwrap)"),
1407 "(#option-unwrap)"
1408 );
1409 assert_eq!(
1410 DocLinkUtils::convert_html_links("(trait.Iterator.html#method.next)"),
1411 "(#iterator-next)"
1412 );
1413 }
1414
1415 #[test]
1416 fn test_convert_html_links_mixed_content() {
1417 // Mixed type and method links in same text
1418 let docs = "See (struct.Foo.html) and (struct.Foo.html#method.bar)";
1419 let result = DocLinkUtils::convert_html_links(docs);
1420 assert_eq!(result, "See (#foo) and (#foo-bar)");
1421 }
1422
1423 #[test]
1424 fn test_convert_html_links_preserves_surrounding_text() {
1425 // Note: underscores in method names are converted to hyphens by slugify_anchor
1426 let docs = "Call `x.(struct.Type.html#method.do_thing)` for effect.";
1427 let result = DocLinkUtils::convert_html_links(docs);
1428 assert_eq!(result, "Call `x.(#type-do-thing)` for effect.");
1429 }
1430}