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(&regex::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(&regex::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}