use std::collections::HashMap;
use std::fmt::Write;
use std::sync::LazyLock;
use regex::Regex;
use rustdoc_types::{Crate, Id, ItemKind};
use crate::linker::{AnchorUtils, LinkRegistry};
use crate::utils::PathUtils;
static HTML_LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"\((struct|enum|trait|fn|type|macro|constant|mod)\.",
r"([A-Za-z_][A-Za-z0-9_]*)\.html",
r"(?:#([a-z]+)\.([A-Za-z_][A-Za-z0-9_]*))?\)",
))
.unwrap()
});
static PATH_REF_LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[([^\]]+)\]\[([a-zA-Z_][a-zA-Z0-9_]*(?:::[a-zA-Z_][a-zA-Z0-9_]*)+)\]").unwrap()
});
static BACKTICK_LINK_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[`([^`]+)`\]").unwrap());
static REFERENCE_LINK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\[`([^`]+)`\]").unwrap());
static REFERENCE_DEF_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^\s*\[[^\]]+\](?:\([^)]*\))?:\s*\S+\s*$").unwrap());
static PLAIN_LINK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[([a-zA-Z_][a-zA-Z0-9_]*)\]").unwrap());
static METHOD_LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[`([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)+)`\]").unwrap()
});
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LineKind {
OpeningFence { bare: bool },
ClosingFence,
CodeContent,
Text,
}
struct CodeBlockTracker {
fence: Option<&'static str>,
}
impl CodeBlockTracker {
const fn new() -> Self {
Self { fence: None }
}
fn classify(&mut self, line: &str) -> LineKind {
let trimmed = line.trim_start();
let detected_fence = if trimmed.starts_with("```") {
Some("```")
} else if trimmed.starts_with("~~~") {
Some("~~~")
} else {
None
};
match (self.fence, detected_fence) {
(Some(open), Some(_)) if trimmed.starts_with(open) => {
self.fence = None;
LineKind::ClosingFence
},
(Some(_), _) => LineKind::CodeContent,
(None, Some(f)) => {
self.fence = Some(f);
let bare = trimmed == "```" || trimmed == "~~~";
LineKind::OpeningFence { bare }
},
(None, None) => LineKind::Text,
}
}
}
pub struct DocLinkProcessor<'a> {
krate: &'a Crate,
link_registry: &'a LinkRegistry,
current_file: &'a str,
path_name_index: HashMap<&'a str, Vec<Id>>,
}
impl<'a> DocLinkProcessor<'a> {
#[must_use]
pub fn with_index(
krate: &'a Crate,
link_registry: &'a LinkRegistry,
current_file: &'a str,
path_name_index: &HashMap<&'a str, Vec<Id>>,
) -> Self {
Self {
krate,
link_registry,
current_file,
path_name_index: path_name_index.clone(),
}
}
#[must_use]
pub fn new(krate: &'a Crate, link_registry: &'a LinkRegistry, current_file: &'a str) -> Self {
let mut path_name_index: HashMap<&'a str, Vec<Id>> = HashMap::new();
for (id, path_info) in &krate.paths {
if let Some(name) = path_info.path.last() {
path_name_index.entry(name.as_str()).or_default().push(*id);
}
}
for ids in path_name_index.values_mut() {
ids.sort_by(|a, b| {
let path_a = krate.paths.get(a).map(|p| &p.path);
let path_b = krate.paths.get(b).map(|p| &p.path);
path_a.cmp(&path_b)
});
}
Self {
krate,
link_registry,
current_file,
path_name_index,
}
}
#[must_use]
pub fn process(&self, docs: &str, item_links: &HashMap<String, Id>) -> String {
let stripped = DocLinkUtils::strip_reference_definitions(docs);
let unhidden = DocLinkUtils::unhide_code_lines(&stripped);
let processed = self.process_links_protected(&unhidden, item_links);
Self::clean_blank_lines(&processed)
}
fn process_links_protected(&self, docs: &str, item_links: &HashMap<String, Id>) -> String {
let mut result = String::with_capacity(docs.len());
let mut tracker = CodeBlockTracker::new();
let mut current_pos = 0;
for line in docs.lines() {
let line_end = current_pos + line.len();
match tracker.classify(line) {
LineKind::OpeningFence { .. } | LineKind::ClosingFence | LineKind::CodeContent => {
_ = write!(result, "{line}");
},
LineKind::Text => {
let processed = self.process_line(line, item_links);
_ = write!(result, "{processed}");
},
}
current_pos = line_end;
if current_pos < docs.len() {
_ = writeln!(result);
current_pos += 1; }
}
result
}
fn process_line(&self, line: &str, item_links: &HashMap<String, Id>) -> String {
if line.trim_start().starts_with("[`") && line.contains("]:") {
return line.to_string();
}
let s = self.process_reference_links(line, item_links);
let s = self.process_path_reference_links(&s, item_links);
let s = self.process_method_links(&s, item_links);
let s = self.process_backtick_links(&s, item_links);
let s = self.process_plain_links(&s, item_links);
self.process_html_links_with_context(&s, item_links)
}
fn process_reference_links(&self, text: &str, item_links: &HashMap<String, Id>) -> String {
DocLinkUtils::replace_with_regex(text, &REFERENCE_LINK_RE, |caps| {
let display_text = &caps[1];
let ref_key = &caps[2];
self.resolve_to_url(ref_key, item_links).map_or_else(
|| caps[0].to_string(),
|url| format!("[{display_text}]({url})"),
)
})
}
fn process_path_reference_links(&self, text: &str, item_links: &HashMap<String, Id>) -> String {
DocLinkUtils::replace_with_regex(text, &PATH_REF_LINK_RE, |caps| {
let display_text = &caps[1];
let rust_path = &caps[2];
self.resolve_to_url(rust_path, item_links).map_or_else(
|| {
if display_text.starts_with('`') && display_text.ends_with('`') {
display_text.to_string()
} else {
format!("`{display_text}`")
}
},
|url| format!("[{display_text}]({url})"),
)
})
}
fn process_method_links(&self, text: &str, item_links: &HashMap<String, Id>) -> String {
DocLinkUtils::replace_with_regex_checked(text, &METHOD_LINK_RE, |caps, rest| {
if rest.starts_with('(') {
return caps[0].to_string();
}
let full_path = &caps[1];
if let Some(last_sep) = full_path.rfind("::") {
let type_part = &full_path[..last_sep];
let method_part = &full_path[last_sep + 2..];
if let Some(link) = self.resolve_method_link(type_part, method_part, item_links) {
return link;
}
}
caps[0].to_string()
})
}
fn process_backtick_links(&self, text: &str, item_links: &HashMap<String, Id>) -> String {
DocLinkUtils::replace_with_regex_checked(text, &BACKTICK_LINK_RE, |caps, rest| {
if rest.starts_with('(') {
return caps[0].to_string();
}
let link_text = &caps[1];
self.resolve_link(link_text, item_links)
})
}
fn process_plain_links(&self, text: &str, item_links: &HashMap<String, Id>) -> String {
DocLinkUtils::replace_with_regex_checked(text, &PLAIN_LINK_RE, |caps, rest| {
if matches!(rest.chars().next(), Some('(' | '[')) {
return caps[0].to_string();
}
let link_text = &caps[1];
if let Some(id) = item_links.get(link_text)
&& let Some(md_link) = self.create_link_for_id(*id, link_text)
{
return md_link;
}
caps[0].to_string()
})
}
fn process_html_links_with_context(
&self,
text: &str,
item_links: &HashMap<String, Id>,
) -> String {
DocLinkUtils::replace_with_regex(text, &HTML_LINK_RE, |caps| {
let item_kind = &caps[1]; let item_name = &caps[2];
if let Some(method_match) = caps.get(4) {
let method_name = method_match.as_str();
if let Some(url) = self.resolve_html_link_to_url(item_name, item_kind, item_links) {
let anchor = AnchorUtils::method_anchor(item_name, method_name);
if url.is_empty() {
return format!("(#{anchor})");
}
return format!("({url}#{anchor})");
}
let anchor = AnchorUtils::method_anchor(item_name, method_name);
return format!("(#{anchor})");
}
if let Some(url) = self.resolve_html_link_to_url(item_name, item_kind, item_links) {
return format!("({url})");
}
String::new()
})
}
fn resolve_html_link_to_url(
&self,
item_name: &str,
item_kind: &str,
item_links: &HashMap<String, Id>,
) -> Option<String> {
if let Some(id) = item_links.get(item_name) {
if let Some(path) = self.link_registry.get_path(*id) {
if path == self.current_file {
if let Some(path_info) = self.krate.paths.get(id)
&& AnchorUtils::item_has_anchor(path_info.kind)
{
return Some(format!("#{}", AnchorUtils::slugify_anchor(item_name)));
}
return Some(String::new());
}
let relative = LinkRegistry::compute_relative_path(self.current_file, path);
return Some(relative);
}
if let Some(path_info) = self.krate.paths.get(id)
&& path_info.crate_id != 0
{
return Self::get_docs_rs_url(path_info);
}
}
if let Some(ids) = self.path_name_index.get(item_name) {
for id in ids {
if let Some(path) = self.link_registry.get_path(*id) {
if path == self.current_file {
if let Some(path_info) = self.krate.paths.get(id)
&& AnchorUtils::item_has_anchor(path_info.kind)
{
return Some(format!("#{}", AnchorUtils::slugify_anchor(item_name)));
}
return Some(String::new());
}
let relative = LinkRegistry::compute_relative_path(self.current_file, path);
return Some(relative);
}
if let Some(path_info) = self.krate.paths.get(id)
&& path_info.crate_id != 0
{
return Self::get_docs_rs_url(path_info);
}
}
}
let mut matches: Vec<_> = self
.krate
.paths
.values()
.filter(|path_info| {
path_info.crate_id != 0
&& path_info.path.last().is_some_and(|name| name == item_name)
&& Self::kind_matches(item_kind, path_info.kind)
})
.collect();
matches.sort_by(|a, b| a.path.cmp(&b.path));
matches
.first()
.and_then(|path_info| Self::get_docs_rs_url(path_info))
}
fn kind_matches(html_kind: &str, item_kind: ItemKind) -> bool {
match html_kind {
"struct" => item_kind == ItemKind::Struct,
"enum" => item_kind == ItemKind::Enum,
"trait" => item_kind == ItemKind::Trait,
"fn" => item_kind == ItemKind::Function,
"type" => item_kind == ItemKind::TypeAlias,
"macro" => item_kind == ItemKind::Macro,
"constant" => item_kind == ItemKind::Constant,
"mod" => item_kind == ItemKind::Module,
_ => false,
}
}
fn clean_blank_lines(docs: &str) -> String {
let mut result = String::with_capacity(docs.len());
let mut prev_blank = false;
for line in docs.lines() {
let is_blank = line.trim().is_empty();
if is_blank && prev_blank {
continue;
}
if !result.is_empty() {
_ = writeln!(result);
}
_ = write!(result, "{line}");
prev_blank = is_blank;
}
result.trim_end().to_string()
}
fn resolve_with_strategies<T, F>(
&self,
link_text: &str,
item_links: &HashMap<String, Id>,
resolver: F,
) -> Option<T>
where
F: Fn(Id, &str) -> Option<T>,
{
let short = PathUtils::short_name(link_text);
if let Some(&id) = item_links.get(link_text)
&& let Some(result) = resolver(id, link_text)
{
return Some(result);
}
for (key, &id) in item_links {
if PathUtils::short_name(key) == short
&& let Some(result) = resolver(id, short)
{
return Some(result);
}
}
if let Some(ids) = self.path_name_index.get(short) {
for &id in ids {
if let Some(result) = resolver(id, short) {
return Some(result);
}
}
}
None
}
fn resolve_to_url(&self, link_text: &str, item_links: &HashMap<String, Id>) -> Option<String> {
self.resolve_with_strategies(link_text, item_links, |id, _display| {
self.get_url_for_id(id)
})
}
fn get_url_for_id(&self, id: Id) -> Option<String> {
if let Some(path) = self.link_registry.get_path(id) {
if path == self.current_file {
if let Some(name) = self.link_registry.get_name(id) {
return Some(format!("#{}", AnchorUtils::slugify_anchor(name)));
}
}
let relative = LinkRegistry::compute_relative_path(self.current_file, path);
return Some(relative);
}
if let Some(path_info) = self.krate.paths.get(&id)
&& path_info.crate_id != 0
{
return Self::get_docs_rs_url(path_info);
}
None
}
fn get_docs_rs_url(path_info: &rustdoc_types::ItemSummary) -> Option<String> {
let path = &path_info.path;
if path.is_empty() {
return None;
}
let crate_name = &path[0];
if path_info.kind == ItemKind::Module {
if path.len() == 1 {
return Some(format!("https://docs.rs/{crate_name}/latest/{crate_name}/"));
}
let module_path = path[1..].join("/");
return Some(format!(
"https://docs.rs/{crate_name}/latest/{crate_name}/{module_path}/index.html"
));
}
let item_path = path[1..].join("/");
let type_prefix = match path_info.kind {
ItemKind::Struct => "struct",
ItemKind::Enum => "enum",
ItemKind::Trait => "trait",
ItemKind::Function => "fn",
ItemKind::Constant => "constant",
ItemKind::TypeAlias => "type",
ItemKind::Macro => "macro",
_ => "index",
};
let item_name = path.last().unwrap_or(crate_name);
if item_path.is_empty() {
Some(format!("https://docs.rs/{crate_name}/latest/{crate_name}/"))
} else {
let dir_path = if path.len() > 2 {
path[1..path.len() - 1].join("/")
} else {
String::new()
};
if dir_path.is_empty() {
Some(format!(
"https://docs.rs/{crate_name}/latest/{crate_name}/{type_prefix}.{item_name}.html"
))
} else {
Some(format!(
"https://docs.rs/{crate_name}/latest/{crate_name}/{dir_path}/{type_prefix}.{item_name}.html"
))
}
}
}
fn resolve_method_link(
&self,
type_name: &str,
method_name: &str,
item_links: &HashMap<String, Id>,
) -> Option<String> {
let short_type = PathUtils::short_name(type_name);
let type_id = item_links.get(type_name).or_else(|| {
item_links
.iter()
.find(|(k, _)| PathUtils::short_name(k) == short_type)
.map(|(_, id)| id)
})?;
let type_path = self.link_registry.get_path(*type_id)?;
let display = format!("{type_name}::{method_name}");
let anchor = AnchorUtils::method_anchor(short_type, method_name);
if type_path == self.current_file {
return Some(format!("[`{display}`](#{anchor})"));
}
let relative = LinkRegistry::compute_relative_path(self.current_file, type_path);
Some(format!("[`{display}`]({relative}#{anchor})"))
}
fn resolve_link(&self, link_text: &str, item_links: &HashMap<String, Id>) -> String {
self.resolve_with_strategies(link_text, item_links, |id, display| {
self.create_link_for_id(id, display)
})
.unwrap_or_else(|| format!("[`{link_text}`]"))
}
fn create_link_for_id(&self, id: Id, display_name: &str) -> Option<String> {
if let Some(link) = self.link_registry.create_link(id, self.current_file) {
return Some(link);
}
if let Some(path) = self.link_registry.get_path(id) {
let clean_name = PathUtils::short_name(display_name);
if path == self.current_file {
let anchor = AnchorUtils::slugify_anchor(clean_name);
return Some(format!("[`{clean_name}`](#{anchor})"));
}
let relative = LinkRegistry::compute_relative_path(self.current_file, path);
return Some(format!("[`{clean_name}`]({relative})"));
}
if let Some(path_info) = self.krate.paths.get(&id)
&& path_info.crate_id != 0
{
return Self::create_docs_rs_link(path_info, display_name);
}
None
}
fn create_docs_rs_link(
path_info: &rustdoc_types::ItemSummary,
display_name: &str,
) -> Option<String> {
let url = Self::get_docs_rs_url(path_info)?;
let clean_name = PathUtils::short_name(display_name);
Some(format!("[`{clean_name}`]({url})"))
}
}
pub struct DocLinkUtils;
impl DocLinkUtils {
#[must_use]
pub fn convert_html_links(docs: &str) -> String {
Self::replace_with_regex(docs, &HTML_LINK_RE, |caps| {
let item_name = &caps[2];
caps.get(4).map_or_else(
|| format!("(#{})", item_name.to_lowercase()),
|method_match| {
let method_name = method_match.as_str();
let anchor = AnchorUtils::method_anchor(item_name, method_name);
format!("(#{anchor})")
},
)
})
}
#[must_use]
pub fn strip_duplicate_title<'a>(docs: &'a str, item_name: &str) -> &'a str {
let Some(first_line) = docs.lines().next() else {
return docs;
};
let Some(title) = first_line.strip_prefix("# ") else {
return docs;
};
let normalized_title = title
.trim()
.replace('`', "")
.replace(['-', ' '], "_")
.to_lowercase();
let normalized_name = item_name.replace('-', "_").to_lowercase();
if normalized_title == normalized_name {
docs[first_line.len()..].trim_start_matches('\n')
} else {
docs
}
}
pub fn strip_reference_definitions(docs: &str) -> String {
REFERENCE_DEF_RE.replace_all(docs, "").to_string()
}
#[must_use]
pub fn unhide_code_lines(docs: &str) -> String {
let mut result = String::with_capacity(docs.len());
let mut tracker = CodeBlockTracker::new();
for line in docs.lines() {
let trimmed = line.trim_start();
let leading_ws = &line[..line.len() - trimmed.len()];
match tracker.classify(line) {
LineKind::OpeningFence { bare } => {
if bare {
_ = write!(result, "{leading_ws}{trimmed}rust");
} else {
_ = write!(result, "{line}");
}
},
LineKind::ClosingFence | LineKind::Text => {
_ = write!(result, "{line}");
},
LineKind::CodeContent => {
if trimmed == "#" {
} else if let Some(rest) = trimmed.strip_prefix("# ") {
_ = write!(result, "{leading_ws}{rest}");
} else {
_ = write!(result, "{line}");
}
},
}
_ = writeln!(result);
}
if !docs.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
result
}
#[must_use]
pub fn convert_path_reference_links(docs: &str) -> String {
Self::replace_with_regex(docs, &PATH_REF_LINK_RE, |caps| {
let display_text = &caps[1];
if display_text.starts_with('`') && display_text.ends_with('`') {
display_text.to_string()
} else {
format!("`{display_text}`")
}
})
}
fn replace_with_regex<F>(text: &str, re: &Regex, replacer: F) -> String
where
F: Fn(®ex::Captures<'_>) -> String,
{
let mut result = String::with_capacity(text.len());
let mut last_end = 0;
for caps in re.captures_iter(text) {
let m = caps.get(0).unwrap();
_ = write!(result, "{}", &text[last_end..m.start()]);
_ = write!(result, "{}", &replacer(&caps));
last_end = m.end();
}
_ = write!(result, "{}", &text[last_end..]);
result
}
fn replace_with_regex_checked<F>(text: &str, re: &Regex, replacer: F) -> String
where
F: Fn(®ex::Captures<'_>, &str) -> String,
{
let mut result = String::with_capacity(text.len());
let mut last_end = 0;
for caps in re.captures_iter(text) {
let m = caps.get(0).unwrap();
_ = write!(result, "{}", &text[last_end..m.start()]);
let rest = &text[m.end()..];
_ = write!(result, "{}", &replacer(&caps, rest));
last_end = m.end();
}
_ = write!(result, "{}", &text[last_end..]);
result
}
}
#[cfg(test)]
mod tests {
use super::DocLinkUtils;
#[test]
fn test_convert_html_links() {
assert_eq!(
DocLinkUtils::convert_html_links("See (enum.Foo.html) for details"),
"See (#foo) for details"
);
assert_eq!(
DocLinkUtils::convert_html_links("Call (struct.Bar.html#method.new)"),
"Call (#bar-new)"
);
assert_eq!(
DocLinkUtils::convert_html_links("Use (struct.HashMap.html#method.insert)"),
"Use (#hashmap-insert)"
);
}
#[test]
fn test_strip_duplicate_title() {
let docs = "# my_crate\n\nThis is the description.";
assert_eq!(
DocLinkUtils::strip_duplicate_title(docs, "my_crate"),
"This is the description."
);
let docs2 = "# Introduction\n\nThis is the description.";
assert_eq!(
DocLinkUtils::strip_duplicate_title(docs2, "my_crate"),
docs2
);
let docs3 = "# `clap_builder`\n\nBuilder implementation.";
assert_eq!(
DocLinkUtils::strip_duplicate_title(docs3, "clap_builder"),
"Builder implementation."
);
let docs4 = "# Serde JSON\n\nJSON serialization.";
assert_eq!(
DocLinkUtils::strip_duplicate_title(docs4, "serde_json"),
"JSON serialization."
);
let docs5 = "# my-crate\n\nDescription.";
assert_eq!(
DocLinkUtils::strip_duplicate_title(docs5, "my_crate"),
"Description."
);
}
#[test]
fn test_strip_reference_definitions() {
let docs = "See [`Foo`] for details.\n\n[`Foo`]: crate::Foo";
let result = DocLinkUtils::strip_reference_definitions(docs);
assert!(result.contains("See [`Foo`]"));
assert!(!result.contains("[`Foo`]: crate::Foo"));
let docs2 = "Use [value] here.\n\n[value]: crate::value::Value";
let result2 = DocLinkUtils::strip_reference_definitions(docs2);
assert!(result2.contains("Use [value]"));
assert!(!result2.contains("[value]: crate::value::Value"));
let docs3 = "See [from_str](#from-str) docs.\n\n[from_str](#from-str): crate::de::from_str";
let result3 = DocLinkUtils::strip_reference_definitions(docs3);
assert!(result3.contains("See [from_str](#from-str)"));
assert!(!result3.contains("[from_str](#from-str): crate::de::from_str"));
let docs4 = "Content.\n\n[a]: path::a\n[b]: path::b\n[`c`]: path::c";
let result4 = DocLinkUtils::strip_reference_definitions(docs4);
assert_eq!(result4.trim(), "Content.");
}
#[test]
fn test_convert_path_reference_links() {
let docs = "[`Tracker`][crate::style::Tracker] is useful";
let result = DocLinkUtils::convert_path_reference_links(docs);
assert_eq!(result, "`Tracker` is useful");
}
#[test]
fn test_unhide_code_lines_strips_hidden_prefix() {
let docs = "```\n# #[cfg(feature = \"test\")]\n# {\nuse foo::bar;\n# }\n```";
let result = DocLinkUtils::unhide_code_lines(docs);
assert_eq!(
result,
"```rust\n#[cfg(feature = \"test\")]\n{\nuse foo::bar;\n}\n```"
);
}
#[test]
fn test_unhide_code_lines_adds_rust_to_bare_fence() {
let docs = "```\nlet x = 1;\n```";
let result = DocLinkUtils::unhide_code_lines(docs);
assert_eq!(result, "```rust\nlet x = 1;\n```");
}
#[test]
fn test_unhide_code_lines_preserves_existing_language() {
let docs = "```python\nprint('hello')\n```";
let result = DocLinkUtils::unhide_code_lines(docs);
assert_eq!(result, "```python\nprint('hello')\n```");
}
#[test]
fn test_unhide_code_lines_handles_tilde_fence() {
let docs = "~~~\ncode\n~~~";
let result = DocLinkUtils::unhide_code_lines(docs);
assert_eq!(result, "~~~rust\ncode\n~~~");
}
#[test]
fn test_unhide_code_lines_lone_hash() {
let docs = "```\n#\nlet x = 1;\n```";
let result = DocLinkUtils::unhide_code_lines(docs);
assert_eq!(result, "```rust\n\nlet x = 1;\n```");
}
#[test]
fn test_convert_html_links_method_anchor_format() {
assert_eq!(
DocLinkUtils::convert_html_links("(struct.Vec.html#method.push)"),
"(#vec-push)"
);
assert_eq!(
DocLinkUtils::convert_html_links("(enum.Option.html#method.unwrap)"),
"(#option-unwrap)"
);
assert_eq!(
DocLinkUtils::convert_html_links("(trait.Iterator.html#method.next)"),
"(#iterator-next)"
);
}
#[test]
fn test_convert_html_links_mixed_content() {
let docs = "See (struct.Foo.html) and (struct.Foo.html#method.bar)";
let result = DocLinkUtils::convert_html_links(docs);
assert_eq!(result, "See (#foo) and (#foo-bar)");
}
#[test]
fn test_convert_html_links_preserves_surrounding_text() {
let docs = "Call `x.(struct.Type.html#method.do_thing)` for effect.";
let result = DocLinkUtils::convert_html_links(docs);
assert_eq!(result, "Call `x.(#type-do-thing)` for effect.");
}
}