use regex::Regex;
use scraper::{Html, Selector};
use std::borrow::Cow;
use std::sync::LazyLock;
const SKIP_TAGS: &[&str] = &["script", "style", "noscript", "iframe"];
const BLOCK_TAGS: &[&str] = &[
"address",
"article",
"aside",
"blockquote",
"br",
"dd",
"div",
"dl",
"dt",
"fieldset",
"figcaption",
"figure",
"footer",
"form",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hr",
"li",
"main",
"nav",
"ol",
"p",
"pre",
"section",
"table",
"tbody",
"tfoot",
"thead",
"tr",
"ul",
];
const BLOCK_SEP: &str = "\u{0}";
const CELL_SEP: &str = "\u{1}";
const PRE_SPACE: char = '\u{2}';
const PRE_NEWLINE: char = '\u{3}';
const PRE_TAB: char = '\u{4}';
static ANCHOR_LINK_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[§\]\([^)]*\)").expect("hardcoded valid regex pattern"));
static SOURCE_LINK_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[Source\]\([^)]*\)").expect("hardcoded valid regex pattern"));
static SRC_LINK_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[\[?src\]?\]\([^)]*\)").expect("hardcoded valid regex pattern"));
static JS_TOGGLE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[\s*\[[-+\x{2212}]\]\s*\]\(javascript:[^\n)]*\)\)?")
.expect("hardcoded valid regex pattern")
});
static JS_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[[^\]\n]*\]\(javascript:[^\n)]*\)\)?").expect("hardcoded valid regex pattern")
});
static EMPTY_LINK_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\(\)").expect("hardcoded valid regex pattern"));
static FRAGMENT_TOGGLE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\(#\)").expect("hardcoded valid regex pattern"));
static RUSTDOC_ITEM_ANCHOR_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"\[([^\]]*)\]\(#(?:(?:method|tymethod|variant|structfield|associatedtype|associatedconstant|reexport)\.|impl-)[^)]*\)",
)
.expect("hardcoded valid regex pattern")
});
static STRAY_COLON_LINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?m)^[ \t]*:{2,}[ \t]*$").expect("hardcoded valid regex pattern")
});
static STRAY_MIDDOT_LINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?m)^[ \t]*\u{00b7}[ \t]*$").expect("hardcoded valid regex pattern")
});
static TRAILING_MIDDOT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?m)[ \t\u{00a0}]*\u{00b7}[ \t\u{00a0}]*$").expect("hardcoded valid regex pattern")
});
static TRAILING_WS_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)[ \t\u{00a0}]+$").expect("hardcoded valid regex pattern"));
static HEADING_TRAILING_HASH_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?m)^(#{1,6}[ \t].*?)[ \t]+#+[ \t]*$").expect("hardcoded valid regex pattern")
});
static SUPERSCRIPT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)<sup\b[^>]*>(.*?)</sup\s*>").expect("hardcoded valid regex pattern")
});
static SUBSCRIPT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)<sub\b[^>]*>(.*?)</sub\s*>").expect("hardcoded valid regex pattern")
});
static INLINE_TAG_STRIP_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?is)<[^>]+>").expect("hardcoded valid regex pattern"));
static NEGATIVE_IMPL_TRAIT_IMAGE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?m)^(#{1,6} +impl\b[^\n]*?)!\[").expect("hardcoded valid regex pattern")
});
static RELATIVE_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[((?:[^\[\]]|\[[^\]]*\])*)\]\(([a-zA-Z0-9._/][^)]*\.html(?:#[^)]*)?)\)")
.expect("hardcoded valid regex pattern")
});
static READ_MORE_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"([ \t]*)\[Read more\]\(([^)]*)\)").expect("hardcoded valid regex pattern")
});
static ITEM_TABLE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)<dl[^>]*\bitem-table\b[^>]*>(.*?)</dl\s*>")
.expect("hardcoded valid regex pattern")
});
static ITEM_TABLE_ROW_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)<dt\b[^>]*>(.*?)</dt\s*>\s*(?:<dd\b[^>]*>(.*?)</dd\s*>)?")
.expect("hardcoded valid regex pattern")
});
static MULTIPLE_NEWLINES_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\n\n\n+").expect("hardcoded valid regex pattern"));
static PRE_BLOCK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)<pre\b.*?</pre\s*>").expect("hardcoded valid regex pattern")
});
static INLINE_LEADING_WS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)[ \t\r\n]*[\r\n\t][ \t\r\n]*(<(?:a|code|em|strong|b|i|span|sup|sub|abbr|kbd|var|cite|q|mark|small|u)\b)",
)
.expect("hardcoded valid regex pattern")
});
static INLINE_TRAILING_WS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)(</(?:a|code|em|strong|b|i|span|sup|sub|abbr|kbd|var|cite|q|mark|small|u)>)[ \t\r\n]*[\r\n\t][ \t\r\n]*(?P<n>[A-Za-z0-9`\[(])",
)
.expect("hardcoded valid regex pattern")
});
static BODY_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("body").expect("hardcoded valid selector"));
static ALL_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("*").expect("hardcoded valid selector"));
static SCRIPT_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("script").expect("hardcoded valid selector"));
static STYLE_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("style").expect("hardcoded valid selector"));
static NOSCRIPT_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("noscript").expect("hardcoded valid selector"));
static IFRAME_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("iframe").expect("hardcoded valid selector"));
static NAV_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("nav").expect("hardcoded valid selector"));
static HEADER_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("header").expect("hardcoded valid selector"));
static FOOTER_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("footer").expect("hardcoded valid selector"));
static ASIDE_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("aside").expect("hardcoded valid selector"));
static BUTTON_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("button").expect("hardcoded valid selector"));
static SUMMARY_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("summary").expect("hardcoded valid selector"));
static SRC_ANCHOR_HTML_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?s)<a\b[^>]*\bclass\s*=\s*['"][^'"]*\bsrc(?:link)?\b[^'"]*['"][^>]*>.*?</a>"#)
.expect("hardcoded valid regex pattern")
});
static ORPHAN_SINCE_MIDDOT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?s)[ \t\u{00a0}]*\u{00b7}[ \t\u{00a0}]*(</span\s*>)")
.expect("hardcoded valid regex pattern")
});
static SINCE_BADGE_GLUED_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?is)(<span\b[^>]*\bclass\s*=\s*["'][^"']*\bsince\b[^"']*["'][^>]*>[^<]*</span\s*>)<"#,
)
.expect("hardcoded valid regex pattern")
});
static UI_ANCHOR_HTML_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?s)<a\b[^>]*\bclass\s*=\s*['"][^'"]*\b(?:anchor|tooltip|test-arrow|scrape-help)\b[^'"]*['"][^>]*>.*?</a>"#,
)
.expect("hardcoded valid regex pattern")
});
static JS_ANCHOR_HTML_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?is)<a\b[^>]*\bhref\s*=\s*['"]\s*javascript:[^>]*>.*?</a>"#)
.expect("hardcoded valid regex pattern")
});
static DANGEROUS_ELEMENT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?is)<script\b[^>]*>.*?</script\s*>|<style\b[^>]*>.*?</style\s*>|<noscript\b[^>]*>.*?</noscript\s*>|<iframe\b[^>]*>.*?</iframe\s*>|<iframe\b[^>]*/>",
)
.expect("hardcoded valid regex pattern")
});
static RUSTDOC_UI_ELEMENT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?is)<rustdoc-(?:toolbar|topbar)\b[^>]*>.*?</rustdoc-(?:toolbar|topbar)\s*>|<rustdoc-(?:toolbar|topbar)\b[^>]*/>",
)
.expect("hardcoded valid regex pattern")
});
static RUSTDOC_BREADCRUMBS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)<div\b[^>]*\brustdoc-breadcrumbs\b[^>]*>.*?</div\s*>")
.expect("hardcoded valid regex pattern")
});
static PROSE_PRE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?is)<pre\b[^>]*\bstyle\s*=\s*["'][^"']*white-space\s*:\s*normal[^"']*["'][^>]*>(.*?)</pre\s*>"#,
)
.expect("hardcoded valid regex pattern")
});
static UNSAFE_FN_MARKER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?is)<sup\b[^>]*\btitle\s*=\s*["']unsafe function["'][^>]*>.*?</sup\s*>"#)
.expect("hardcoded valid regex pattern")
});
static HIDEME_SUMMARY_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?is)<summary\b[^>]*\bclass\s*=\s*["'][^"']*\bhideme\b[^"']*["'][^>]*>.*?</summary\s*>"#,
)
.expect("hardcoded valid regex pattern")
});
static IMPL_DOCBLOCK_IN_SUMMARY_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?is)</h3>\s*<div class="docblock">(.*?)</div>\s*</section>\s*</summary>"#)
.expect("hardcoded valid regex pattern")
});
static STAB_PORTABILITY_TITLE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?is)<span\b[^>]*\bclass\s*=\s*["'][^"']*\bportability\b[^"']*["'][^>]*\btitle\s*=\s*"([^"]*)"[^>]*>.*?</span\s*>"#,
)
.expect("hardcoded valid regex pattern")
});
static STAB_PORTABILITY_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?is)<span\b[^>]*\bclass\s*=\s*["'][^"']*\bportability\b[^"']*["'][^>]*>(.*?)</span\s*>"#,
)
.expect("hardcoded valid regex pattern")
});
static STAB_BADGE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?is)<span\b[^>]*\bclass\s*=\s*["'][^"']*\bstab\b[^"']*["'][^>]*>(.*?)</span\s*>"#,
)
.expect("hardcoded valid regex pattern")
});
static ITEM_INFO_OPEN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?is)(<span\b[^>]*\bclass\s*=\s*["'][^"']*\bitem-info\b[^"']*["'][^>]*>)"#)
.expect("hardcoded valid regex pattern")
});
static EMOJI_SPAN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?is)(<span\b[^>]*\bclass\s*=\s*["'][^"']*\bemoji\b[^"']*["'][^>]*>.*?</span\s*>)"#,
)
.expect("hardcoded valid regex pattern")
});
static STRUCTFIELD_SPAN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?is)<span\b[^>]*\bclass\s*=\s*["'][^"']*\bstructfield\b[^"']*["'][^>]*>(.*?)</span\s*>"#,
)
.expect("hardcoded valid regex pattern")
});
static WHERE_DIV_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?is)<div\b[^>]*\bclass\s*=\s*["'][^"']*\bwhere\b[^"']*["'][^>]*>(?P<w>.*?)</div\s*>"#,
)
.expect("hardcoded valid regex pattern")
});
static MAIN_CONTENT_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("#main-content").expect("hardcoded valid selector"));
static RUSTDOC_BODY_WRAPPER_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("#rustdoc_body_wrapper").expect("hardcoded valid selector"));
static H1_SELECTOR: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("h1").expect("hardcoded valid selector"));
#[must_use]
fn rewrite_item_tables(html: &str) -> String {
ITEM_TABLE_REGEX
.replace_all(html, |caps: ®ex::Captures| {
let inner = &caps[1];
let mut out = String::from("<ul>");
for row in ITEM_TABLE_ROW_REGEX.captures_iter(inner) {
let name = row.get(1).map_or("", |m| m.as_str()).trim();
if name.is_empty() {
continue;
}
out.push_str("<li>");
out.push_str(name);
let desc = row.get(2).map_or("", |m| m.as_str()).trim();
if !desc.is_empty() {
out.push_str(" \u{2014} ");
out.push_str(desc);
}
out.push_str("</li>");
}
out.push_str("</ul>");
out
})
.into_owned()
}
static CODE_ATTRIBUTE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?is)<div\b[^>]*\bclass\s*=\s*["'][^"']*\bcode-attribute\b[^"']*["'][^>]*>(.*?)</div\s*>"#,
)
.expect("hardcoded valid regex pattern")
});
#[must_use]
fn rewrite_code_attributes(html: &str) -> String {
CODE_ATTRIBUTE_REGEX
.replace_all(html, "${1}\n")
.into_owned()
}
static CODE_HEADER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?is)<h([34])\b[^>]*\bclass\s*=\s*["'][^"']*\bcode-header\b[^"']*["'][^>]*>(.*?)</h[34]\s*>"#,
)
.expect("hardcoded valid regex pattern")
});
static SIG_OPEN_PAREN_WRAP_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\(\s*\n\s*").expect("hardcoded valid regex pattern"));
static SIG_CLOSE_PAREN_WRAP_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r",?\s*\n\s*\)").expect("hardcoded valid regex pattern"));
static SIG_NEWLINE_RUN_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\s*\n\s*").expect("hardcoded valid regex pattern"));
fn rewrite_code_headers(html: &str) -> String {
CODE_HEADER_REGEX
.replace_all(html, |caps: ®ex::Captures| {
let level = &caps[1];
let inner = &caps[2];
let (open, close) = if level == "4" {
(r#"<p class="code-header">"#.to_string(), "</p>".to_string())
} else {
(
format!("<h{level} class=\"code-header\">"),
format!("</h{level}>"),
)
};
if !inner.contains('\n') {
return format!("{open}{inner}{close}");
}
let inner = SIG_OPEN_PAREN_WRAP_REGEX.replace_all(inner, "(");
let inner = SIG_CLOSE_PAREN_WRAP_REGEX.replace_all(&inner, ")");
let inner = SIG_NEWLINE_RUN_REGEX.replace_all(&inner, " ");
format!("{open}{inner}{close}")
})
.into_owned()
}
fn rewrite_where_clauses(html: &str) -> String {
let collapse = |caps: ®ex::Captures| -> String {
let inner = caps.name("w").map_or("", |m| m.as_str());
format!(
" {} ",
inner.split_whitespace().collect::<Vec<_>>().join(" ")
)
};
let mut out = String::with_capacity(html.len());
let mut last = 0;
for m in PRE_BLOCK_REGEX.find_iter(html) {
out.push_str(&WHERE_DIV_REGEX.replace_all(&html[last..m.start()], &collapse));
out.push_str(&WHERE_DIV_REGEX.replace_all(m.as_str(), "\n${w}\n"));
last = m.end();
}
out.push_str(&WHERE_DIV_REGEX.replace_all(&html[last..], &collapse));
out
}
fn rewrite_portability_badges(html: &str) -> String {
let with_titles = STAB_PORTABILITY_TITLE_REGEX.replace_all(html, |caps: ®ex::Captures| {
format!(" ({})", badge_title_to_html(&caps[1]))
});
STAB_PORTABILITY_REGEX
.replace_all(&with_titles, " (${1})")
.into_owned()
}
#[must_use]
fn badge_title_to_html(title: &str) -> String {
let parts: Vec<&str> = title.split('`').collect();
if parts.len().is_multiple_of(2) {
return title.to_string();
}
let mut out = String::with_capacity(title.len() + 13);
for (i, part) in parts.iter().enumerate() {
if i % 2 == 1 {
out.push_str("<code>");
out.push_str(part);
out.push_str("</code>");
} else {
out.push_str(part);
}
}
out
}
#[must_use]
fn rewrite_stab_badges(html: &str) -> String {
STAB_BADGE_REGEX.replace_all(html, " (${1})").into_owned()
}
#[must_use]
pub fn clean_html(html: &str) -> String {
let html = SRC_ANCHOR_HTML_REGEX.replace_all(html, "");
let html = ORPHAN_SINCE_MIDDOT_REGEX.replace_all(&html, " ${1}");
let html = JS_ANCHOR_HTML_REGEX.replace_all(&html, "");
let html = UI_ANCHOR_HTML_REGEX.replace_all(&html, "");
let html = SINCE_BADGE_GLUED_REGEX.replace_all(&html, "${1} <");
let html = DANGEROUS_ELEMENT_REGEX.replace_all(&html, "");
let html = RUSTDOC_UI_ELEMENT_REGEX.replace_all(&html, "");
let html = RUSTDOC_BREADCRUMBS_REGEX.replace_all(&html, "");
let html = PROSE_PRE_REGEX.replace_all(&html, "<blockquote>${1}</blockquote>");
let html = UNSAFE_FN_MARKER_REGEX.replace_all(&html, " (unsafe)");
let html = HIDEME_SUMMARY_REGEX.replace_all(&html, "");
let html = rewrite_where_clauses(&html);
let html = rewrite_code_headers(&html);
let html = rewrite_code_attributes(&html);
let html = rewrite_portability_badges(&html);
let html = rewrite_stab_badges(&html);
let html = EMOJI_SPAN_REGEX.replace_all(&html, "${1} ");
let html = ITEM_INFO_OPEN_REGEX.replace_all(&html, " ${1}");
let html = rewrite_item_tables(&html);
let html = STRUCTFIELD_SPAN_REGEX.replace_all(&html, "<p>${1}</p>");
let html = IMPL_DOCBLOCK_IN_SUMMARY_REGEX.replace_all(
&html,
r#"</h3></section></summary><div class="docblock">${1}</div>"#,
);
let document = Html::parse_document(&html);
remove_unwanted_elements(&document, &html)
}
#[must_use]
fn escape_html_text(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
#[inline]
fn remove_unwanted_elements(document: &Html, original_html: &str) -> String {
let mut replacements: Vec<(String, Option<String>)> = Vec::new();
for element in document.select(&SCRIPT_SELECTOR) {
replacements.push((element.html(), None));
}
for element in document.select(&STYLE_SELECTOR) {
replacements.push((element.html(), None));
}
for element in document.select(&NOSCRIPT_SELECTOR) {
replacements.push((element.html(), None));
}
for element in document.select(&IFRAME_SELECTOR) {
replacements.push((element.html(), None));
}
for element in document.select(&NAV_SELECTOR) {
replacements.push((element.html(), None));
}
for element in document.select(&HEADER_SELECTOR) {
replacements.push((element.html(), None));
}
for element in document.select(&FOOTER_SELECTOR) {
replacements.push((element.html(), None));
}
for element in document.select(&ASIDE_SELECTOR) {
replacements.push((element.html(), None));
}
for element in document.select(&BUTTON_SELECTOR) {
replacements.push((element.html(), None));
}
for element in document.select(&SUMMARY_SELECTOR) {
let element_html = element.html();
let text_content: String = element.text().collect();
replacements.push((element_html, Some(escape_html_text(&text_content))));
}
if replacements.is_empty() {
return apply_regex_patterns(original_html);
}
replacements.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
let mut result = document
.select(&BODY_SELECTOR)
.next()
.map_or_else(|| document.root_element().html(), |body| body.inner_html());
for (element_html, replacement) in replacements {
result = if let Some(text) = replacement {
result.replace(&element_html, &text)
} else {
result.replace(&element_html, "")
};
}
apply_regex_patterns(&result)
}
static COMBINED_CLEANUP_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?:<link[^>]*>|<meta[^>]*>|</?details[^>]*>|Copy item path|Expand description|Expand attributes|\[§\]\([^)]*\)|\[Source\]\([^)]*\)|\[[^\]]*\]\([a-zA-Z][^)]*\.html\))",
)
.expect("hardcoded valid regex pattern")
});
#[inline]
fn apply_regex_patterns(html: &str) -> String {
COMBINED_CLEANUP_REGEX.replace_all(html, "").into_owned()
}
#[must_use]
pub fn html_to_text(html: &str) -> String {
decode_pre(&html_to_text_raw(html))
}
fn html_to_text_raw(html: &str) -> String {
let document = Html::parse_document(html);
let mut text_parts = Vec::new();
if let Some(body) = document.select(&BODY_SELECTOR).next() {
extract_text_excluding_skip_tags(&body, &mut text_parts);
} else {
if let Some(root) = document.select(&ALL_SELECTOR).next() {
extract_text_excluding_skip_tags(&root, &mut text_parts);
}
}
collapse_block_whitespace(&text_parts.join(""))
}
fn extract_text_excluding_skip_tags(
element: &scraper::element_ref::ElementRef,
text_parts: &mut Vec<String>,
) {
let tag_name = element.value().name().to_lowercase();
if SKIP_TAGS.contains(&tag_name.as_str()) {
return;
}
for child in element.children() {
match child.value() {
scraper::node::Node::Text(text) => {
text_parts.push(text.to_string());
}
scraper::node::Node::Element(_) => {
if let Some(child_ref) = scraper::element_ref::ElementRef::wrap(child) {
let name = child_ref.value().name().to_lowercase();
if name == "pre" {
let raw = child_ref.text().collect::<String>();
text_parts.push(BLOCK_SEP.to_string());
text_parts.push(encode_pre(raw.trim_matches('\n')));
text_parts.push(BLOCK_SEP.to_string());
continue;
}
if name == "sup" || name == "sub" {
let mut inner_parts = Vec::new();
extract_text_excluding_skip_tags(&child_ref, &mut inner_parts);
let inner = inner_parts
.join("")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if !inner.is_empty() {
let (open, close) = if name == "sup" {
("^(", ")")
} else {
("_(", ")")
};
text_parts.push(format!("{open}{inner}{close}"));
}
continue;
}
let is_cell = name == "td" || name == "th";
let is_block = !is_cell && BLOCK_TAGS.contains(&name.as_str());
let sep = if is_cell { CELL_SEP } else { BLOCK_SEP };
if is_cell || is_block {
text_parts.push(sep.to_string());
}
extract_text_excluding_skip_tags(&child_ref, text_parts);
if is_block {
text_parts.push(sep.to_string());
}
}
}
_ => {}
}
}
}
#[must_use]
pub fn extract_documentation_html(html: &str) -> String {
let main_content = extract_main_content(html);
clean_html(&main_content)
}
static INLINE_CODE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?is)<code\b[^>]*>.*?</code\s*>").expect("valid regex"));
static ANCHOR_TAG_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?is)</?a\b[^>]*>").expect("valid regex"));
#[must_use]
fn flatten_links_in_inline_code(html: &str) -> String {
let strip = |segment: &str| -> String {
INLINE_CODE_REGEX
.replace_all(segment, |caps: ®ex::Captures| {
ANCHOR_TAG_REGEX.replace_all(&caps[0], "").into_owned()
})
.into_owned()
};
let mut out = String::with_capacity(html.len());
let mut last = 0;
for m in PRE_BLOCK_REGEX.find_iter(html) {
out.push_str(&strip(&html[last..m.start()]));
out.push_str(m.as_str());
last = m.end();
}
out.push_str(&strip(&html[last..]));
out
}
static PRE_LANG_OPEN_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?is)<pre\b([^>]*)>(\s*<code\b[^>]*>)?").expect("valid regex"));
static PRE_CLASS_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"(?is)class\s*=\s*["']([^"']*)["']"#).expect("valid regex"));
const CODE_FENCE_SENTINEL: char = '\u{2}';
#[must_use]
fn detect_pre_language(pre_attrs: &str) -> Option<String> {
let class = PRE_CLASS_REGEX.captures(pre_attrs)?.get(1)?.as_str();
for tok in class.split_whitespace() {
if let Some(lang) = tok.strip_prefix("language-") {
if !lang.is_empty() {
return Some(lang.to_string());
}
}
}
if class.split_whitespace().any(|t| t == "rust") {
return Some("rust".to_string());
}
None
}
#[must_use]
fn inject_code_fence_language(html: &str) -> String {
PRE_LANG_OPEN_REGEX
.replace_all(html, |caps: ®ex::Captures| {
let whole = &caps[0];
match detect_pre_language(&caps[1]) {
Some(lang) => {
format!("{whole}{CODE_FENCE_SENTINEL}{lang}{CODE_FENCE_SENTINEL}\n")
}
None => whole.to_string(),
}
})
.into_owned()
}
fn normalize_inline_leading_whitespace(html: &str) -> String {
let fix = |segment: &str| -> String {
let leading = INLINE_LEADING_WS_REGEX.replace_all(segment, " $1");
INLINE_TRAILING_WS_REGEX
.replace_all(&leading, "$1 $n")
.into_owned()
};
let mut out = String::with_capacity(html.len());
let mut last = 0;
for m in PRE_BLOCK_REGEX.find_iter(html) {
out.push_str(&fix(&html[last..m.start()]));
out.push_str(m.as_str());
last = m.end();
}
out.push_str(&fix(&html[last..]));
out
}
#[must_use]
pub fn extract_documentation(html: &str) -> String {
let main_content = extract_main_content(html);
let cleaned_html = clean_html(&main_content);
let cleaned_html = flatten_links_in_inline_code(&cleaned_html);
let cleaned_html = inject_code_fence_language(&cleaned_html);
let cleaned_html = normalize_inline_leading_whitespace(&cleaned_html);
let markdown = html2md::parse_html(&cleaned_html);
clean_markdown(&markdown)
}
fn unescape_markdown(markdown: &str) -> String {
const ESCAPED: [char; 6] = ['<', '>', '*', '_', '~', '\\'];
let mut out = String::with_capacity(markdown.len());
let mut in_fence = false;
for line in markdown.split_inclusive('\n') {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
out.push_str(line);
continue;
}
if in_fence {
out.push_str(line);
continue;
}
let chars: Vec<char> = line.chars().collect();
let mut in_code = false;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '`' {
let start = i;
while i < chars.len() && chars[i] == '`' {
i += 1;
}
for _ in start..i {
out.push('`');
}
in_code = !in_code;
continue;
}
if c == '\\' && !in_code && i + 1 < chars.len() && ESCAPED.contains(&chars[i + 1]) {
out.push(chars[i + 1]);
i += 2;
continue;
}
out.push(c);
i += 1;
}
}
out
}
static CODE_FENCE_SENTINEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?m)^([ \t]*`{3,})[ \t]*\r?\n[ \t]*\x02([^\x02\r\n]*)\x02[ \t]*\r?\n")
.expect("valid regex")
});
static ORPHAN_FENCE_SENTINEL_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\x02[^\x02\n]*\x02\n?").expect("valid regex"));
#[must_use]
fn restore_code_fence_language(markdown: &str) -> String {
let with_lang = CODE_FENCE_SENTINEL_REGEX.replace_all(markdown, "${1}${2}\n");
ORPHAN_FENCE_SENTINEL_REGEX
.replace_all(&with_lang, "")
.into_owned()
}
#[inline]
fn clean_markdown(markdown: &str) -> String {
let markdown = restore_code_fence_language(markdown);
let unescaped = unescape_markdown(&markdown);
let unescaped = SUPERSCRIPT_REGEX.replace_all(&unescaped, |caps: ®ex::Captures| {
let inner = INLINE_TAG_STRIP_REGEX.replace_all(&caps[1], "");
let inner = inner.trim();
if inner.is_empty() {
String::new()
} else {
format!("^({inner})")
}
});
let unescaped = SUBSCRIPT_REGEX.replace_all(&unescaped, |caps: ®ex::Captures| {
let inner = INLINE_TAG_STRIP_REGEX.replace_all(&caps[1], "");
let inner = inner.trim();
if inner.is_empty() {
String::new()
} else {
format!("_({inner})")
}
});
let unescaped = NEGATIVE_IMPL_TRAIT_IMAGE_REGEX.replace_all(&unescaped, r"${1}\![");
let result = JS_TOGGLE_REGEX.replace_all(&unescaped, Cow::Borrowed(""));
let result = JS_LINK_REGEX.replace_all(&result, Cow::Borrowed(""));
let result = SOURCE_LINK_REGEX.replace_all(&result, Cow::Borrowed(""));
let result = SRC_LINK_REGEX.replace_all(&result, Cow::Borrowed(""));
let result = READ_MORE_LINK_REGEX.replace_all(&result, |caps: ®ex::Captures| {
let ws = &caps[1];
let url = &caps[2];
if url.contains("://") {
format!("{ws}[Read more]({url})")
} else {
String::new()
}
});
let result = RELATIVE_LINK_REGEX.replace_all(&result, |caps: ®ex::Captures| {
let text = &caps[1];
let url = &caps[2];
if url.contains("://") {
format!("[{text}]({url})")
} else {
text.to_string()
}
});
let result = RUSTDOC_ITEM_ANCHOR_REGEX.replace_all(&result, Cow::Borrowed("$1"));
let result = ANCHOR_LINK_REGEX.replace_all(&result, Cow::Borrowed(""));
let result = FRAGMENT_TOGGLE_REGEX.replace_all(&result, |caps: ®ex::Captures| {
let label = &caps[1];
if label.chars().any(|c| c.is_ascii_alphanumeric()) {
label.to_string()
} else {
String::new()
}
});
let result = EMPTY_LINK_REGEX.replace_all(&result, Cow::Borrowed("$1"));
let result = STRAY_COLON_LINE_REGEX.replace_all(&result, Cow::Borrowed(""));
let result = STRAY_MIDDOT_LINE_REGEX.replace_all(&result, Cow::Borrowed(""));
let result = TRAILING_MIDDOT_REGEX.replace_all(&result, Cow::Borrowed(""));
let result = TRAILING_WS_REGEX.replace_all(&result, Cow::Borrowed(""));
let result = HEADING_TRAILING_HASH_REGEX.replace_all(&result, Cow::Borrowed("$1"));
let result = tidy_blockquotes(&result);
let result = MULTIPLE_NEWLINES_REGEX.replace_all(&result, Cow::Borrowed("\n\n"));
result.trim().to_string()
}
#[must_use]
fn tidy_blockquotes(markdown: &str) -> String {
let is_quote = |l: &str| l.trim_start().starts_with('>');
let is_empty_quote = |l: &str| is_quote(l) && l.chars().all(|c| c == '>' || c.is_whitespace());
let lines: Vec<&str> = markdown.lines().collect();
let mut out: Vec<String> = Vec::with_capacity(lines.len());
let mut i = 0;
while i < lines.len() {
if !is_quote(lines[i]) {
out.push(lines[i].to_string());
i += 1;
continue;
}
let start = i;
while i < lines.len() && is_quote(lines[i]) {
i += 1;
}
let block = &lines[start..i];
let first = block.iter().position(|l| !is_empty_quote(l));
let last = block.iter().rposition(|l| !is_empty_quote(l));
if let (Some(first), Some(last)) = (first, last) {
let mut prev_empty = false;
for line in &block[first..=last] {
let empty = is_empty_quote(line);
if empty && prev_empty {
continue; }
out.push((*line).to_string());
prev_empty = empty;
}
}
}
out.join("\n")
}
#[inline]
fn extract_main_content(html: &str) -> String {
let document = Html::parse_document(html);
if let Some(main_section) = document.select(&MAIN_CONTENT_SELECTOR).next() {
return main_section.html();
}
if let Some(wrapper) = document.select(&RUSTDOC_BODY_WRAPPER_SELECTOR).next() {
return wrapper.html();
}
html.to_string()
}
#[must_use]
pub fn page_h1_text(html: &str) -> Option<String> {
let document = Html::parse_document(html);
let collapse = |element: scraper::ElementRef| -> String {
clean_whitespace(&element.text().collect::<String>())
};
let h1 = document
.select(&MAIN_CONTENT_SELECTOR)
.next()
.and_then(|main| main.select(&H1_SELECTOR).next().map(collapse))
.or_else(|| document.select(&H1_SELECTOR).next().map(collapse));
h1.filter(|s| !s.is_empty())
}
fn heading_contains_identifier(heading: &str, ident: &str) -> bool {
heading
.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.any(|token| token == ident)
}
#[must_use]
pub fn is_item_fallback_page(html: &str, item_path: &str) -> bool {
let leaf = item_path.rsplit("::").next().unwrap_or(item_path).trim();
if leaf.is_empty() {
return false;
}
match page_h1_text(html) {
Some(h1) => !heading_contains_identifier(&h1, leaf),
None => false,
}
}
#[must_use]
pub fn extract_search_results(html: &str, item_path: &str) -> String {
let main_content = extract_main_content(html);
let cleaned_html = clean_html(&main_content);
let cleaned_html = flatten_links_in_inline_code(&cleaned_html);
let cleaned_html = inject_code_fence_language(&cleaned_html);
let cleaned_html = normalize_inline_leading_whitespace(&cleaned_html);
let markdown = html2md::parse_html(&cleaned_html);
let cleaned_markdown = clean_markdown(&markdown);
if cleaned_markdown.trim().is_empty() {
return format!("Documentation for '{item_path}' not found");
}
if is_item_fallback_page(html, item_path) {
format!(
"## Documentation: {item_path}\n\n_No dedicated documentation page was found for `{item_path}`; showing the closest available page (its containing type or the crate overview) instead. It may be a method, associated item, or trait method, or it may not exist._\n\n{cleaned_markdown}"
)
} else {
format!("## Documentation: {item_path}\n\n{cleaned_markdown}")
}
}
#[must_use]
pub fn extract_documentation_as_text(html: &str) -> String {
let main_content = extract_main_content(html);
let cleaned_html = clean_html(&main_content);
let text = html_to_text_raw(&cleaned_html);
let normalized = normalize_lines(&text.replace('\u{00a7}', " "));
let normalized = TRAILING_MIDDOT_REGEX.replace_all(&normalized, "");
strip_trailing_line_whitespace(&decode_pre(&normalized))
}
#[inline]
fn collapse_block_whitespace(text: &str) -> String {
text.split(BLOCK_SEP)
.map(|seg| {
if seg.contains(CELL_SEP) {
let mut cells: Vec<String> = seg
.split(CELL_SEP)
.map(|cell| cell.split_whitespace().collect::<Vec<_>>().join(" "))
.collect();
if cells.first().is_some_and(String::is_empty) {
cells.remove(0);
}
if cells.iter().all(String::is_empty) {
String::new()
} else {
cells.join(" | ")
}
} else {
seg.split_whitespace().collect::<Vec<_>>().join(" ")
}
})
.filter(|seg| !seg.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
#[inline]
fn normalize_lines(text: &str) -> String {
text.lines()
.map(|line| line.split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
#[inline]
fn strip_trailing_line_whitespace(text: &str) -> String {
text.split('\n')
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n")
}
#[inline]
fn clean_whitespace(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn encode_pre(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
' ' => out.push(PRE_SPACE),
'\n' => out.push(PRE_NEWLINE),
'\t' => out.push(PRE_TAB),
'\r' => {}
other => out.push(other),
}
}
out
}
fn decode_pre(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
PRE_SPACE => out.push(' '),
PRE_NEWLINE => out.push('\n'),
PRE_TAB => out.push('\t'),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_text_strips_old_rustdoc_src_and_toggle_anchors() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<h1>Crate serde",
"<a id=\"toggle-all-docs\" href=\"javascript:void(0)\" title=\"collapse all docs\">",
"[<span class='inner'>TOGGLEMARK</span>]</a>",
"<a class='srclink' href='../src/serde/lib.rs.html#9-267' title='goto source code'>[src]</a>",
"</h1><p>Real doc.</p>",
"</section></body></html>"
);
let text = extract_documentation_as_text(html);
assert!(!text.contains("[src]"), "src link leaked: {text:?}");
assert!(!text.contains("TOGGLEMARK"), "toggle leaked: {text:?}");
assert!(text.contains("Crate serde"), "heading dropped: {text:?}");
assert!(text.contains("Real doc."), "content dropped: {text:?}");
}
#[test]
fn test_markdown_strips_trailing_heading_hashes() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<h3>Examples</h3>",
"<h4>pub fn get(&self)</h4>",
"<p>Body text.</p>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(md.contains("### Examples"), "h3 missing: {md:?}");
assert!(!md.contains("Examples ###"), "trailing hashes left: {md:?}");
assert!(md.contains("#### pub fn get(&self)"), "h4 missing: {md:?}");
assert!(!md.contains(") ####"), "trailing hashes left: {md:?}");
}
#[test]
fn test_markdown_restores_space_before_inline_link() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<p>replaced on a per-<code>HashMap</code> basis using the\n",
"<a href=\"trait.Default.html#tymethod.default\"><code>default</code></a>, ",
"<a href=\"struct.HashMap.html#method.with_hasher\"><code>with_hasher</code></a> methods.</p>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
md.contains("using the `default`"),
"missing space before inline link: {md:?}"
);
assert!(
md.contains("per-`HashMap`"),
"spurious space inserted into hyphenated code: {md:?}"
);
}
#[test]
fn test_where_clause_detached_from_declaration() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<pre class=\"rust item-decl\"><code>pub struct Vec<T, A = ",
"<a class=\"struct\" href=\"struct.Global.html\">Global</a>>",
"<div class=\"where\">where\n A: <a class=\"trait\" href=\"trait.Allocator.html\">Allocator</a>,</div>",
"{ <span class=\"comment\">/* private fields */</span> }</code></pre>",
"<h4 class=\"code-header\">pub fn retain<F>(&mut self, f: F)",
"<div class=\"where\">where\n F: <a class=\"trait\" href=\"trait.FnMut.html\">FnMut</a>,</div></h4>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
md.contains("Global>\nwhere") && md.contains("Allocator,\n{"),
"where clause not broken in code block: {md:?}"
);
assert!(!md.contains("Global>where"), "glued where survived: {md:?}");
assert!(
md.contains("f: F) where F:"),
"where not separated in header: {md:?}"
);
let text = extract_documentation_as_text(html);
assert!(
text.contains("Global>\nwhere"),
"where clause not broken in text: {text:?}"
);
assert!(
!text.contains("Global>where"),
"glued where in text: {text:?}"
);
}
#[test]
fn test_text_signature_has_no_trailing_whitespace_before_where() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<pre class=\"rust item-decl\"><code>fn step_by(self, step: usize) -> ",
"StepBy<Self> ",
"<div class=\"where\">where\n Self: Sized,</div>",
"</code></pre>",
"</section></body></html>"
);
let text = extract_documentation_as_text(html);
assert!(
!text.lines().any(|l| l.ends_with(' ') || l.ends_with('\t')),
"text output has a line with trailing whitespace: {text:?}"
);
assert!(
text.contains("-> StepBy<Self>") && text.contains("where"),
"signature/where content lost: {text:?}"
);
}
#[test]
fn test_ui_glyph_anchors_stripped() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<details class=\"toggle implementors-toggle\" open><summary>",
"<section id=\"impl-Clone\" class=\"impl\">",
"<a href=\"#impl-Clone\" class=\"anchor\">\u{00a7}</a>",
"<h3 class=\"code-header\">impl Clone for Foo</h3></section></summary></details>",
"<details class=\"toggle method-toggle\" open><summary>",
"<section id=\"method.keys\" class=\"method\">",
"<h4 class=\"code-header\">fn <a href=\"#method.keys\" class=\"fn\">keys</a>(&self) -> ",
"<a class=\"struct\" href=\"struct.Keys.html\">Keys</a> ",
"<a href=\"#\" class=\"tooltip\" data-notable-ty=\"Keys\">\u{24d8}</a></h4>",
"</section></summary></details>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
!md.contains('\u{00a7}'),
"section-sign anchor leaked into markdown: {md:?}"
);
assert!(
!md.contains('\u{24d8}'),
"notable-trait marker leaked into markdown: {md:?}"
);
assert!(
md.contains("impl Clone for Foo"),
"impl header lost: {md:?}"
);
let text = extract_documentation_as_text(html);
assert!(
!text.contains('\u{00a7}') && !text.contains('\u{24d8}'),
"UI glyph leaked into text: {text:?}"
);
}
#[test]
fn test_scrape_help_question_mark_anchor_stripped() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<div class=\"docblock scraped-example-list\"><span></span>",
"<h5 id=\"scraped-examples\">",
"<a href=\"#scraped-examples\">Examples found in repository</a>",
"<a class=\"scrape-help\" href=\"../scrape-examples-help.html\">?</a>",
"</h5></div>",
"</section></body></html>"
);
for out in [
extract_documentation(html),
extract_documentation_as_text(html),
extract_documentation_html(html),
] {
assert!(
!out.contains("?</a>") && !out.contains("scrape-help"),
"scrape-help link leaked: {out:?}"
);
assert!(
out.contains("Examples found in repository"),
"scraped-example heading text lost: {out:?}"
);
}
let md = extract_documentation(html);
assert!(
!md.contains("repository)?") && !md.contains("repository ?"),
"stray scrape-help `?` leaked into markdown: {md:?}"
);
}
#[test]
fn test_multiline_signature_collapsed_to_single_line() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<section id=\"method.try_lock_owned\" class=\"method\">",
"<h4 class=\"code-header\">pub fn <a href=\"#m\" class=\"fn\">try_lock_owned</a>(\n",
" self: <a class=\"struct\" href=\"struct.Arc.html\">Arc</a><Self>,\n",
") -> <a class=\"enum\" href=\"enum.Result.html\">Result</a><T></h4></section>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
md.contains("(self: Arc<Self>) -> Result<T>"),
"multi-line signature not collapsed cleanly (markdown): {md:?}"
);
assert!(
!md.contains("( self") && !md.contains(", )") && !md.contains(",\n)"),
"signature spacing artifacts survived: {md:?}"
);
assert!(
!md.contains("Result<T>\n)") && !md.contains(",\n)"),
"heading split across lines: {md:?}"
);
let text = extract_documentation_as_text(html);
assert!(
text.contains("try_lock_owned(self: Arc<Self>) -> Result<T>"),
"multi-line signature not collapsed cleanly (text): {text:?}"
);
}
#[test]
fn test_rustdoc_ui_web_components_stripped() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<div class=\"main-heading\"><h1>Struct Foo</h1>",
"<rustdoc-toolbar></rustdoc-toolbar></div>",
"<rustdoc-topbar><h2><a href=\"#\">Foo</a></h2></rustdoc-topbar>",
"<p>Body.</p>",
"</section></body></html>"
);
let out = extract_documentation_html(html);
assert!(
!out.contains("rustdoc-toolbar") && !out.contains("rustdoc-topbar"),
"rustdoc UI web-component leaked into html: {out:?}"
);
assert!(out.contains("Body."), "body content lost: {out:?}");
}
#[test]
fn test_rustdoc_breadcrumbs_stripped() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<div class=\"main-heading\">",
"<div class=\"rustdoc-breadcrumbs\"><a href=\"../index.html\">std</a>",
"::<wbr><a href=\"index.html\">vec</a></div>",
"<h1>Struct Vec</h1></div>",
"<p>A contiguous growable array type.</p>",
"</section></body></html>"
);
for out in [
extract_documentation(html),
extract_documentation_as_text(html),
extract_documentation_html(html),
] {
assert!(
!out.contains("rustdoc-breadcrumbs"),
"breadcrumb element leaked: {out:?}"
);
assert!(
!out.contains("std::vec"),
"dangling breadcrumb line leaked: {out:?}"
);
assert!(
out.contains("Vec") && out.contains("contiguous growable"),
"real content lost: {out:?}"
);
}
}
#[test]
fn test_prose_admonition_pre_becomes_blockquote_not_code() {
let html = concat!(
"<section id=\"main-content\">",
"<p>Intro.</p>",
"<div class=\"example-wrap\"><pre class=\"compile_fail\" ",
"style=\"white-space:normal;font:inherit;\">",
"<p><strong>Warning</strong>: Do not hold <code>Span::enter</code> ",
"across an await point.</p></pre></div>",
"<p>Outro.</p>",
"</section>"
);
let md = extract_documentation(html);
assert!(
!md.contains("```"),
"prose admonition rendered as code fence in markdown: {md:?}"
);
assert!(
md.contains("> ") && md.contains("Warning"),
"admonition not rendered as blockquote: {md:?}"
);
assert!(
md.contains("`Span::enter`"),
"inline code lost in admonition: {md:?}"
);
let html_out = extract_documentation_html(html);
assert!(
!html_out.contains("white-space:normal"),
"prose pre survived in html output: {html_out:?}"
);
let code_html = concat!(
"<section id=\"main-content\">",
"<pre class=\"rust rust-example-rendered\"><code>let x = 1;</code></pre>",
"</section>"
);
assert!(
extract_documentation(code_html).contains("```"),
"genuine code example lost its fence"
);
}
#[test]
fn test_unsafe_function_marker_rendered_as_annotation() {
let html = concat!(
"<section id=\"main-content\"><dl class=\"item-table\">",
"<dt><a class=\"fn\" href=\"fn.copy.html\">copy</a>",
"<sup title=\"unsafe function\">\u{26a0}</sup></dt>",
"<dd>Copies bytes.</dd></dl></section>"
);
for out in [
extract_documentation(html),
extract_documentation_as_text(html),
extract_documentation_html(html),
] {
assert!(
!out.contains('\u{26a0}'),
"unsafe marker glyph leaked: {out:?}"
);
assert!(
!out.contains("^("),
"unsafe marker rendered as superscript: {out:?}"
);
assert!(
out.contains("(unsafe)"),
"unsafe annotation missing: {out:?}"
);
}
}
#[test]
fn test_hideme_show_methods_toggle_stripped() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<pre class=\"rust item-decl\"><code>pub trait Iterator {\n",
" type Item;\n",
"<details class=\"toggle type-contents-toggle\">",
"<summary class=\"hideme\"><span>Show 76 methods</span></summary>",
" // Required method\n",
" fn next(&mut self) -> Option<Self::Item>;\n",
"</details>}</code></pre>",
"</section></body></html>"
);
for out in [
extract_documentation(html),
extract_documentation_as_text(html),
extract_documentation_html(html),
] {
assert!(
!out.contains("Show 76 methods"),
"collapse toggle label leaked: {out:?}"
);
assert!(
out.contains("// Required method"),
"details content lost: {out:?}"
);
}
}
#[test]
fn test_impl_block_docblock_not_glued_to_declaration() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<div id=\"implementations-list\">",
"<details class=\"toggle implementors-toggle\" open><summary>",
"<section id=\"impl-Arg\" class=\"impl\">",
"<h3 class=\"code-header\">impl Arg</h3>",
"<div class=\"docblock\"><h4 id=\"basic-api\">Basic API</h4></div>",
"</section></summary>",
"<div class=\"impl-items\"><details class=\"toggle method-toggle\" open>",
"<summary><section id=\"method.new\" class=\"method\">",
"<h4 class=\"code-header\">pub fn new() -> Arg</h4></section></summary>",
"<div class=\"docblock\"><p>Create a new Arg.</p></div></details></div>",
"</details></div>",
"</section></body></html>"
);
for out in [
extract_documentation(html),
extract_documentation_as_text(html),
extract_documentation_html(html),
] {
assert!(
!out.contains("ArgBasic API"),
"impl declaration glued to docblock heading: {out:?}"
);
assert!(
out.contains("Basic API"),
"impl-block docblock heading lost: {out:?}"
);
}
}
#[test]
fn test_undocumented_assoc_item_not_rendered_as_heading() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<details class=\"toggle\" open><summary>",
"<section id=\"associatedconstant.DOC\" class=\"associatedconstant\">",
"<h4 class=\"code-header\">pub const DOC: Self</h4></section></summary>",
"<div class=\"docblock\"><p>Documented constant.</p></div></details>",
"<section id=\"associatedconstant.BARE\" class=\"associatedconstant\">",
"<h4 class=\"code-header\">pub const BARE: Self</h4></section>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
!md.contains("#### pub const BARE"),
"undocumented assoc const rendered as a heading: {md:?}"
);
assert!(
md.contains("pub const BARE: Self"),
"undocumented assoc const signature lost: {md:?}"
);
assert!(
md.contains("pub const DOC: Self") && md.contains("Documented constant."),
"documented assoc const rendering changed: {md:?}"
);
}
#[test]
fn test_multiline_signature_in_pre_block_preserved() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<pre class=\"rust\"><code>foo(\n a,\n b,\n);</code></pre>",
"</section></body></html>"
);
let text = extract_documentation_as_text(html);
assert!(
text.contains("foo(") && text.contains("a,") && text.contains("b,"),
"pre-block example was altered: {text:?}"
);
}
#[test]
fn test_emoji_badge_separated_from_text() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<div class=\"stab unstable\">",
"<span class=\"emoji\">\u{1f52c}</span>",
"<span>This is a nightly-only experimental API.</span></div>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
md.contains("\u{1f52c} This is a nightly-only"),
"emoji not separated from text in markdown: {md:?}"
);
assert!(
!md.contains("\u{1f52c}This"),
"emoji still glued in markdown: {md:?}"
);
}
#[test]
fn test_playground_run_button_stripped() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<div class=\"example-wrap\"><pre class=\"rust\"><code>let x = 1;</code></pre>",
"<a class=\"test-arrow\" target=\"_blank\" title=\"Run code\" ",
"href=\"https://play.rust-lang.org/?code=fn+main()+%7B%7D\"></a></div>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
!md.contains("play.rust-lang.org"),
"playground run button leaked into markdown: {md:?}"
);
assert!(!md.contains("[]("), "empty-text link survived: {md:?}");
assert!(md.contains("let x = 1;"), "example code lost: {md:?}");
}
#[test]
fn test_orphan_since_middot_collapsed() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<details class=\"toggle method-toggle\" open><summary>",
"<section id=\"method.next\" class=\"method\">",
"<span class=\"rightside\"><span class=\"since\" title=\"Stable since Rust version 1.0.0\">1.0.0</span>",
" \u{00b7} <a class=\"src\" href=\"../../src/x.html#1\">Source</a></span>",
"<h4 class=\"code-header\">fn <a href=\"#method.next\" class=\"fn\">next</a>(&mut self)</h4>",
"</section></summary></details>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
md.contains("1.0.0 fn next"),
"version not cleanly separated from signature: {md:?}"
);
assert!(
!md.contains("1.0.0 \u{00b7}") && !md.contains("\u{00b7} fn next"),
"orphan middot survived: {md:?}"
);
}
#[test]
fn test_since_badge_separated_from_signature() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<details class=\"toggle method-toggle\" open><summary>",
"<section id=\"method.clone_from\" class=\"method trait-impl\">",
"<span class=\"rightside\"><span class=\"since\" title=\"Stable since Rust version 1.0.0\">1.0.0</span></span>",
"<a href=\"#method.clone_from\" class=\"anchor\">\u{00a7}</a>",
"<h4 class=\"code-header\">fn <a href=\"#method.clone_from\" class=\"fn\">clone_from</a>(&mut self, source: &Self)</h4>",
"</section></summary></details>",
"</section></body></html>"
);
let md = extract_documentation(html);
let text = extract_documentation_as_text(html);
assert!(
md.contains("1.0.0 fn clone_from"),
"since badge glued onto signature (markdown): {md:?}"
);
assert!(
!md.contains("1.0.0fn"),
"since badge still fused in markdown: {md:?}"
);
assert!(
!text.contains("1.0.0fn"),
"since badge still fused in text: {text:?}"
);
}
#[test]
fn test_generics_survive_summary_method_header() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<details class=\"toggle method-toggle\" open><summary>",
"<section id=\"method.size_hint\" class=\"method\">",
"<span class=\"rightside\"><span class=\"since\" title=\"Stable since Rust version 1.0.0\">1.0.0</span>",
" \u{00b7} <a class=\"src\" href=\"../../src/x.html#1\">Source</a></span>",
"<h4 class=\"code-header\">fn <a href=\"#method.size_hint\" class=\"fn\">size_hint</a>",
"(&self) -> (<a class=\"primitive\" href=\"../primitive.usize.html\">usize</a>, ",
"<a class=\"enum\" href=\"../option/enum.Option.html\">Option</a><",
"<a class=\"primitive\" href=\"../primitive.usize.html\">usize</a>>)</h4>",
"</section></summary></details>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
md.contains("Option<usize>"),
"generic args dropped from summary method header (markdown): {md:?}"
);
let text = extract_documentation_as_text(html);
assert!(
text.contains("Option<usize>"),
"generic args dropped from summary method header (text): {text:?}"
);
}
#[test]
fn test_escape_html_text_reescapes_special_chars() {
assert_eq!(escape_html_text("Vec<u8>"), "Vec<u8>");
assert_eq!(escape_html_text("a & b"), "a & b");
assert_eq!(escape_html_text("Option<&T>"), "Option<&T>");
}
#[test]
fn test_portability_badge_separated_from_item_name() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<dl class=\"item-table\">",
"<dt><a class=\"mod\" href=\"fs/index.html\">fs</a>",
"<span class=\"stab portability\" title=\"Available on crate feature `fs` only\">",
"<code>fs</code></span></dt><dd>Async files.</dd>",
"<dt><a class=\"mod\" href=\"io/index.html\">io</a></dt><dd>Async IO.</dd>",
"</dl></section></body></html>"
);
let md = extract_documentation(html);
assert!(
md.contains("fs (Available on crate feature `fs` only)"),
"feature badge not separated/labelled: {md:?}"
);
assert!(!md.contains("fs`fs`"), "glued badge survived: {md:?}");
assert!(
md.contains("io — Async IO.") || md.contains("io —"),
"io item altered: {md:?}"
);
let text = extract_documentation_as_text(html);
assert!(
text.contains("fs (Available on crate feature fs only)"),
"text badge not separated: {text:?}"
);
}
#[test]
fn test_code_attribute_on_own_line() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<pre class=\"rust item-decl\"><code>",
"<div class=\"code-attribute\">#[repr(i8)]</div>",
"<div class=\"code-attribute\">#[non_exhaustive]</div>",
"pub enum Ordering {\n Less = -1,\n}</code></pre>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
md.contains("#[repr(i8)]\npub enum Ordering")
|| md.contains("#[non_exhaustive]\npub enum Ordering"),
"attribute glued onto declaration in markdown: {md:?}"
);
assert!(
!md.contains("]pub enum"),
"attribute still glued in markdown: {md:?}"
);
let text = extract_documentation_as_text(html);
assert!(
!text.contains("]pub enum"),
"attribute still glued in text: {text:?}"
);
assert!(
text.contains("#[repr(i8)]") && text.contains("#[non_exhaustive]"),
"an attribute was dropped: {text:?}"
);
}
#[test]
fn test_reexport_link_flattened_in_inline_code() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<h2 id=\"reexports\">Re-exports</h2>",
"<dl class=\"item-table reexports\"><dt id=\"reexport.rand_core\">",
"<code>pub use <a class=\"mod\" ",
"href=\"https://docs.rs/rand_core/0.10.0/rand_core/index.html\" ",
"title=\"mod rand_core\">rand_core</a>;</code></dt></dl>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
md.contains("`pub use rand_core;`"),
"re-export code span malformed: {md:?}"
);
assert!(
!md.contains("[rand_core]"),
"unrenderable link survived inside code span: {md:?}"
);
let html_out = extract_documentation_html(html);
assert!(
html_out.contains("href=\"https://docs.rs/rand_core/0.10.0/rand_core/index.html\""),
"html output dropped the re-export link: {html_out:?}"
);
}
#[test]
fn test_code_fence_language_preserved() {
let html = concat!(
"<div class=\"docblock\">",
"<pre class=\"rust rust-example-rendered\"><code>let x = 1;</code></pre>",
"<pre class=\"language-toml\"><code>v = 1</code></pre>",
"<pre><code>plain</code></pre>",
"</div>"
);
let md = extract_documentation(html);
assert!(md.contains("```rust"), "rust fence hint missing: {md:?}");
assert!(md.contains("```toml"), "toml fence hint missing: {md:?}");
assert!(
!md.contains('\u{2}'),
"internal sentinel leaked into markdown: {md:?}"
);
let text = extract_documentation_as_text(html);
assert!(
!text.contains('\u{2}'),
"sentinel leaked into text: {text:?}"
);
assert!(
!text.contains("```rust"),
"text format gained a fence hint: {text:?}"
);
let html_out = extract_documentation_html(html);
assert!(
!html_out.contains('\u{2}'),
"sentinel leaked into html: {html_out:?}"
);
}
#[test]
fn test_portability_badge_feature_with_underscore_not_escaped() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<div class=\"item-name\">",
"<a class=\"fn\" href=\"fn.fill.html\">fill</a>",
"<span class=\"stab portability\" ",
"title=\"Available on crate feature `thread_rng` only\">",
"<code>thread_rng</code></span></div>",
"<div class=\"desc\">Fill any type.</div>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
md.contains("Available on crate feature `thread_rng` only"),
"feature code span malformed: {md:?}"
);
assert!(
!md.contains("thread\\_rng"),
"stray underscore escape in feature name: {md:?}"
);
let text = extract_documentation_as_text(html);
assert!(
text.contains("Available on crate feature thread_rng only"),
"text feature name malformed: {text:?}"
);
}
#[test]
fn test_stab_badge_separated_from_item_name() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<dl class=\"item-table\">",
"<dt><a class=\"enum\" href=\"enum.TryReserveErrorKind.html\">",
"TryReserve<wbr>Error<wbr>Kind</a><wbr>",
"<span class=\"stab unstable\" title=\"\">Experimental</span></dt>",
"<dd>Details of the allocation.</dd>",
"<dt><a class=\"enum\" href=\"enum.Plain.html\">Plain</a></dt><dd>Stable item.</dd>",
"</dl></section></body></html>"
);
let md = extract_documentation(html);
assert!(
md.contains("TryReserveErrorKind (Experimental)"),
"stab badge not separated/labelled: {md:?}"
);
assert!(
!md.contains("KindExperimental"),
"glued stab badge survived: {md:?}"
);
assert!(
md.contains("Plain — Stable item."),
"unbadged item altered: {md:?}"
);
let text = extract_documentation_as_text(html);
assert!(
text.contains("TryReserveErrorKind (Experimental)"),
"text stab badge not separated: {text:?}"
);
}
#[test]
fn test_deprecation_badge_separated_from_signature() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<details class=\"toggle method-toggle\" open><summary>",
"<section id=\"method.description\" class=\"method\">",
"<h4 class=\"code-header\">fn <a href=\"#method.description\" class=\"fn\">description</a>",
"(&self) -> &<a class=\"primitive\" href=\"../primitive.str.html\">str</a></h4></section>",
"<span class=\"item-info\"><div class=\"stab deprecated\">",
"<span class=\"emoji\">\u{1f44e}</span>",
"<span>Deprecated since 1.42.0: <p>use the Display impl or to_string()</p></span>",
"</div></span></summary></details>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
!md.contains("str\u{1f44e}"),
"deprecation badge glued onto signature (markdown): {md:?}"
);
assert!(
md.contains("str \u{1f44e}") || md.contains("&str \u{1f44e}"),
"deprecation badge not space-separated (markdown): {md:?}"
);
let text = extract_documentation_as_text(html);
assert!(
!text.contains("str\u{1f44e}"),
"deprecation badge glued onto signature (text): {text:?}"
);
}
#[test]
fn test_blockquote_empty_marker_lines_removed() {
let single = concat!(
"<html><body><section id=\"main-content\">",
"<blockquote><p><strong>Note here</strong></p></blockquote>",
"<p>after</p></section></body></html>"
);
let md = extract_documentation(single);
assert!(
md.contains("> **Note here**"),
"blockquote content missing: {md:?}"
);
assert!(
!md.lines().any(|l| l.trim() == ">"),
"empty blockquote marker line survived: {md:?}"
);
let multi = concat!(
"<html><body><section id=\"main-content\">",
"<blockquote><p>First para.</p><p>Second para.</p></blockquote>",
"</section></body></html>"
);
let md = extract_documentation(multi);
assert!(
md.contains("> First para.\n>\n> Second para."),
"multi-paragraph blockquote break not preserved: {md:?}"
);
}
#[test]
fn test_superscript_footnote_converted_in_markdown() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<p>zero-padded to 2 digits. ",
"<sup id=\"fnref1\"><a href=\"#fn1\">1</a></sup></p>",
"<p>water is H<sub>2</sub>O.</p>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
!md.contains("<sup") && !md.contains("</sup>") && !md.contains("<a href"),
"superscript/anchor HTML leaked into markdown: {md:?}"
);
assert!(
md.contains("2 digits. ^(1)"),
"footnote reference not converted to ^(1): {md:?}"
);
assert!(
md.contains("H_(2)O"),
"subscript not converted to _(...): {md:?}"
);
let html_out = extract_documentation_html(html);
assert!(
html_out.contains("<sup") && html_out.contains("<sub"),
"html format wrongly stripped super/subscript: {html_out:?}"
);
}
#[test]
fn test_markdown_restores_space_after_inline_link() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<p>moved into the <a href=\"https://docs.rs/tokio-stream\">tokio-stream</a>\n",
"crate. See <a href=\"struct.X.html\">X</a>\nfor details.</p>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
md.contains("tokio-stream) crate"),
"missing space after external link: {md:?}"
);
assert!(
md.contains("See X for details"),
"missing space after downgraded link: {md:?}"
);
let aside = concat!(
"<html><body><section id=\"main-content\">",
"<p>would violate <a href=\"x.html\">the rules of references</a>\n",
"(though possible).</p>",
"</section></body></html>"
);
let aside_md = extract_documentation(aside);
assert!(
aside_md.contains("references (though"),
"missing space before parenthetical aside: {aside_md:?}"
);
let call = concat!(
"<html><body><section id=\"main-content\">",
"<p>call <a href=\"x.html\">foo</a>(arg) now</p>",
"</section></body></html>"
);
let call_md = extract_documentation(call);
assert!(
call_md.contains("foo(arg)"),
"spurious space inserted into call expression: {call_md:?}"
);
}
#[test]
fn test_markdown_preserves_code_block_whitespace() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<pre><code>fn main() {\n",
" let x =\n",
" <a href=\"x.html\">HashMap</a>::new();\n",
"}</code></pre>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
md.contains(" let x ="),
"code block indentation collapsed: {md:?}"
);
}
#[test]
fn test_markdown_unescapes_identifiers_outside_code() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<h1>Crate serde_json</h1>",
"<p>Use <code>serde_json::value</code> to build <code>Vec<u8></code>.</p>",
"<p>pub fn get(&self) -> Option<&Value></p>",
"<pre><code>let v: Vec<u8> = path\\to;</code></pre>",
"</section></body></html>"
);
let md = extract_documentation(html);
assert!(
md.contains("Crate serde_json"),
"heading still escaped: {md:?}"
);
assert!(
md.contains("-> Option<&Value>"),
"signature still escaped: {md:?}"
);
assert!(!md.contains("\\_"), "stray underscore escape: {md:?}");
assert!(
!md.contains("\\<") && !md.contains("\\>"),
"stray angle escape: {md:?}"
);
assert!(
md.contains("`serde_json::value`"),
"inline code mangled: {md:?}"
);
assert!(md.contains("path\\to"), "fenced backslash altered: {md:?}");
}
#[test]
fn test_clean_html_strips_oddly_formatted_block_elements() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<nav class=\"sidebar\">NAVLEAK</nav>",
"<header data-x=\"1\">HEADERLEAK</header>",
"<footer >FOOTERLEAK</footer>",
"<aside role=\"note\">ASIDELEAK</aside>",
"<p>Real doc.</p>",
"</section></body></html>"
);
let cleaned = clean_html(html);
for leak in ["NAVLEAK", "HEADERLEAK", "FOOTERLEAK", "ASIDELEAK"] {
assert!(!cleaned.contains(leak), "{leak} leaked: {cleaned}");
}
assert!(cleaned.contains("Real doc."), "content dropped: {cleaned}");
}
#[test]
fn test_clean_html_removes_source_links() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<a class=\"src rightside\" href=\"../src/foo/lib.rs.html#1-2\">Source</a>",
"<a class=\"src\" href=\"../src/foo/lib.rs.html#5\">Source</a>",
"<p>Real documentation text.</p>",
"</section></body></html>"
);
let text = extract_documentation_as_text(html);
assert!(text.contains("Real documentation text."));
assert!(!text.contains("Source"), "source label leaked: {text}");
}
#[test]
fn test_html_to_text_superscript_uses_caret_notation() {
let html = "<p>zero-padded to 2 digits. <sup id=\"f\"><a href=\"#fn1\">1</a></sup></p> <p>water is H<sub>2</sub>O.</p>";
let text = html_to_text(html);
assert!(
text.contains("2 digits. ^(1)"),
"superscript not rendered as ^(1): {text:?}"
);
assert!(
text.contains("H_(2)O"),
"subscript not rendered as _(2): {text:?}"
);
assert!(
!text.contains("<sup") && !text.contains("<a href"),
"raw tags leaked into text: {text:?}"
);
}
#[test]
fn test_html_to_text_table_rows_stay_on_one_line() {
let html = concat!(
"<table><thead><tr><th>Spec.</th><th>Example</th><th>Description</th></tr></thead>",
"<tbody><tr><td>%Y</td><td>2001</td><td>The full year.</td></tr>",
"<tr><td>%m</td><td>07</td><td>Month number.</td></tr></tbody></table>"
);
let text = html_to_text(html);
assert!(
text.contains("Spec. | Example | Description"),
"header row not joined with ` | `: {text:?}"
);
assert!(
text.contains("%Y | 2001 | The full year."),
"data row not joined with ` | `: {text:?}"
);
assert!(
text.contains("The full year.\n%m | 07 | Month number."),
"rows not on separate lines: {text:?}"
);
}
#[test]
fn test_html_to_text_table_preserves_empty_leading_cell() {
let html = concat!(
"<table><thead><tr><th></th><th>get(i)</th><th>insert(i)</th></tr></thead>",
"<tbody><tr><td>Vec</td><td>O(1)</td><td>O(n-i)</td></tr></tbody></table>"
);
let text = html_to_text(html);
let header = text
.lines()
.find(|l| l.contains("get(i)"))
.expect("header row missing");
let data = text
.lines()
.find(|l| l.contains("Vec"))
.expect("data row missing");
assert_eq!(
header.matches('|').count(),
data.matches('|').count(),
"header/data column counts misaligned: header={header:?} data={data:?}"
);
assert_eq!(
data.trim(),
"Vec | O(1) | O(n-i)",
"data row not joined correctly: {data:?}"
);
}
#[test]
fn test_html_to_text_drops_empty_spacer_rows() {
let html = concat!(
"<table><tbody>",
"<tr><td>%h</td><td>Jul</td><td>Same as %b.</td></tr>",
"<tr><td></td><td></td><td></td></tr>",
"<tr><td>%d</td><td>08</td><td>Day number.</td></tr>",
"</tbody></table>"
);
let text = html_to_text(html);
assert!(
!text.lines().any(|l| l.trim() == "| |" || l.trim() == "|"),
"empty spacer row rendered as pipe noise: {text:?}"
);
assert!(
text.contains("%h | Jul | Same as %b.") && text.contains("%d | 08 | Day number."),
"data rows lost: {text:?}"
);
}
#[test]
fn test_structfield_spans_render_on_separate_lines() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<h2>Fields</h2>",
"<span id=\"structfield.sa_family\" class=\"structfield section-header\">",
"<a href=\"#structfield.sa_family\" class=\"anchor field\">\u{a7}</a>",
"<code>sa_family: <a class=\"type\" href=\"type.sa_family_t.html\">sa_family_t</a></code></span>",
"<span id=\"structfield.sa_data\" class=\"structfield section-header\">",
"<a href=\"#structfield.sa_data\" class=\"anchor field\">\u{a7}</a>",
"<code>sa_data: [<a class=\"type\" href=\"type.c_char.html\">c_char</a>; 14]</code></span>",
"</section></body></html>"
);
let text = extract_documentation_as_text(html);
assert!(
!text.contains("sa_family_tsa_data"),
"struct field tokens fused in text: {text:?}"
);
assert!(
text.contains("sa_family: sa_family_t") && text.contains("sa_data: [c_char; 14]"),
"field declarations missing in text: {text:?}"
);
let md = extract_documentation(html);
assert!(
!md.lines()
.any(|l| l.contains("sa_family") && l.contains("sa_data")),
"struct fields glued on one line in markdown: {md:?}"
);
}
#[test]
fn test_html_to_text_separates_block_elements() {
let html = "<ul><li>Dl_info</li><li>Elf32_Chdr</li><li>Foo</li></ul>";
let text = html_to_text(html);
assert!(
!text.contains("Dl_infoElf32"),
"block text glued together: {text}"
);
assert!(
text.contains("Dl_info\nElf32_Chdr\nFoo"),
"blocks not on separate lines: {text}"
);
}
#[test]
fn test_item_index_table_renders_as_separate_items() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<dl class=\"item-table\">",
"<dt><a class=\"struct\" href=\"struct.Dl_info.html\">Dl_info</a></dt>",
"<dt><a class=\"struct\" href=\"struct.Elf32_Chdr.html\">Elf32_Chdr</a></dt>",
"<dt><a class=\"trait\" href=\"trait.Foo.html\">Foo</a></dt>",
"<dd>A foo trait.</dd>",
"</dl></section></body></html>"
);
let md = extract_documentation(html);
assert!(!md.contains("infoElf32"), "item names concatenated: {md}");
assert!(
md.contains("Dl") && md.contains("info"),
"missing Dl_info: {md}"
);
assert!(md.contains("Elf32"), "missing Elf32_Chdr: {md}");
assert!(md.contains("Foo"), "missing Foo: {md}");
assert!(md.contains("A foo trait."), "missing description: {md}");
assert!(
md.matches("* ").count() >= 3,
"expected separate list items, got: {md}"
);
}
#[test]
fn test_extract_documentation_html_returns_clean_main_content() {
let html = concat!(
"<!DOCTYPE html><html><head><link rel=\"search\" href=\"/opensearch.xml\">",
"<script>var x=1;</script></head><body><nav>Nav</nav>",
"<section id=\"main-content\"><h1>Crate foo</h1><p>Body text.</p>",
"<a class=\"src\" href=\"../src/foo.rs.html\">Source</a></section>",
"<footer>Footer</footer></body></html>"
);
let out = extract_documentation_html(html);
assert!(out.contains("Body text."), "missing body: {out}");
assert!(out.contains("<h1>") || out.contains("Crate foo"));
assert!(!out.contains("<!DOCTYPE"), "doctype leaked: {out}");
assert!(!out.contains("opensearch"), "head link leaked: {out}");
assert!(!out.contains("<script"), "script leaked: {out}");
assert!(!out.contains("Nav"), "nav leaked: {out}");
assert!(!out.contains("Footer"), "footer leaked: {out}");
assert!(!out.contains("Source"), "src link leaked: {out}");
}
#[test]
fn test_clean_html_removes_script() {
let html = "<html><script>var x = 1;</script><body>Hello</body></html>";
let cleaned = clean_html(html);
assert!(!cleaned.contains("script"));
assert!(!cleaned.contains("var x"));
assert!(cleaned.contains("Hello"));
}
#[test]
fn test_clean_html_strips_details_toggle_wrappers() {
let html = r#"<html><body><section id="main-content"><details class="toggle top-doc" open=""><summary>Expand description</summary><h2>MyCrate</h2><p>Useful docs.</p></details></section></body></html>"#;
let cleaned = clean_html(html);
assert!(!cleaned.contains("<details"));
assert!(!cleaned.contains("</details>"));
assert!(!cleaned.contains("Expand description"));
assert!(cleaned.contains("MyCrate"));
assert!(cleaned.contains("Useful docs."));
}
#[test]
fn test_extract_documentation_as_text_strips_ui_cruft() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<button>Copy item path</button>",
"<a class=\"anchor\" href=\"#x\">\u{00a7}</a>",
"<details class=\"toggle top-doc\" open=\"\"><summary>Expand description</summary>",
"<p>Real documentation text.</p></details>",
"</section></body></html>"
);
let text = extract_documentation_as_text(html);
assert!(text.contains("Real documentation text."));
assert!(!text.contains("Copy item path"));
assert!(!text.contains("Expand description"));
assert!(!text.contains('\u{00a7}'));
}
#[test]
fn test_text_strips_trailing_orphan_middot() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<div class=\"out-of-band\">1.0.0 \u{00b7} ",
"<a class=\"src\" href=\"../src/x.rs.html\">source</a></div>",
"<p>Body text.</p>",
"</section></body></html>"
);
let text = extract_documentation_as_text(html);
assert!(text.contains("Body text."), "body dropped: {text:?}");
assert!(
!text.contains("1.0.0 \u{00b7}"),
"orphan middot survived in text: {text:?}"
);
}
#[test]
fn test_extract_documentation_has_no_details_markup() {
let html = r#"<html><body><section id="main-content"><details class="toggle top-doc" open=""><summary>Expand description</summary><h2>MyCrate</h2><p>Hello world.</p></details></section></body></html>"#;
let md = extract_documentation(html);
assert!(!md.contains("<details"));
assert!(!md.contains("Expand description"));
assert!(md.contains("MyCrate"));
assert!(md.contains("Hello world."));
}
#[test]
fn test_clean_html_removes_dangerous_elements_with_irregular_whitespace() {
let html = concat!(
"<html><body><section id=\"main-content\">",
"<script defer >alert('xss')</script>",
"<STYLE type=\"text/css\" >.evil{color:red}</STYLE>",
"<noscript >NoScriptContent</noscript>",
"<iframe src=\"http://evil.example\"></iframe>",
"<p>Safe documentation.</p>",
"</section></body></html>"
);
let cleaned = clean_html(html);
assert!(!cleaned.contains("alert"), "script leaked: {cleaned}");
assert!(!cleaned.contains(".evil"), "style leaked: {cleaned}");
assert!(
!cleaned.contains("NoScriptContent"),
"noscript leaked: {cleaned}"
);
assert!(
!cleaned.contains("evil.example"),
"iframe leaked: {cleaned}"
);
assert!(cleaned.contains("Safe documentation."));
}
#[test]
fn test_clean_html_removes_style() {
let html = "<html><style>.foo { color: red; }</style><body>Content</body></html>";
let cleaned = clean_html(html);
assert!(!cleaned.contains("style"));
assert!(!cleaned.contains(".foo"));
assert!(cleaned.contains("Content"));
}
#[test]
fn test_html_to_text_removes_tags() {
let html = "<p>Hello <strong>World</strong>!</p>";
let text = html_to_text(html);
assert!(!text.contains('<'));
assert!(!text.contains('>'));
assert!(text.contains("Hello"));
assert!(text.contains("World"));
}
#[test]
fn test_html_to_text_excludes_script_and_style_recursively() {
let html = "<body>Hello<script>var secret = 1;</script> <div><style>.x{color:red}</style>World</div> <noscript>NOSCRIPT</noscript></body>";
let text = html_to_text(html);
assert!(text.contains("Hello"), "text: {text}");
assert!(text.contains("World"), "text: {text}");
assert!(!text.contains("secret"), "script content leaked: {text}");
assert!(!text.contains("color:red"), "style content leaked: {text}");
assert!(
!text.contains("NOSCRIPT"),
"noscript content leaked: {text}"
);
}
#[test]
fn test_html_to_text_preserves_inline_runs() {
let html = "<body><p>de<wbr>serializing data</p>\n<div><code>RandomState</code>, <code>Global</code>></div></body>";
let text = html_to_text(html);
assert!(text.contains("deserializing"), "split word: {text}");
assert!(!text.contains("de serializing"), "spurious space: {text}");
assert!(text.contains("RandomState,"), "space before comma: {text}");
assert!(
text.contains("data\nRandomState"),
"lost block separation: {text}"
);
}
#[test]
fn test_html_to_text_handles_entities() {
let html = r"<p>Tom & Jerry</p>";
let text = html_to_text(html);
assert!(text.contains('&') || text.contains("Tom") || text.contains("Jerry"));
}
#[test]
fn test_clean_whitespace() {
assert_eq!(clean_whitespace(" hello world "), "hello world");
assert_eq!(clean_whitespace(" hello world "), "hello world");
assert_eq!(clean_whitespace("\t\nhello\n\tworld\t\n"), "hello world");
}
#[test]
fn test_extract_documentation() {
let html = "<html><body><h1>Title</h1><p>Content</p></body></html>";
let docs = extract_documentation(html);
assert!(docs.contains("Title"));
assert!(docs.contains("Content"));
}
#[test]
fn test_extract_search_results_crate_fallback_adds_note() {
let html = "<html><body><section id=\"main-content\"><h1>Crate serde</h1><p>Crate docs.</p></section></body></html>";
let result = extract_search_results(html, "DoesNotExist");
assert!(result.contains("## Documentation: DoesNotExist"));
assert!(
result.contains("No dedicated documentation page was found"),
"missing fallback note: {result}"
);
}
#[test]
fn test_extract_search_results_direct_item_no_note() {
let html = "<html><body><section id=\"main-content\"><h1>Function spawn</h1><p>Spawns.</p></section></body></html>";
let result = extract_search_results(html, "spawn");
assert!(result.contains("## Documentation: spawn"));
assert!(!result.contains("No dedicated documentation page was found"));
}
#[test]
fn test_extract_search_results_found() {
let html = "<html><body><h1>Result</h1></body></html>";
let result = extract_search_results(html, "serde::Serialize");
assert!(result.contains("Documentation"));
assert!(result.contains("serde::Serialize"));
assert!(result.contains("Result"));
}
#[test]
fn test_extract_search_results_not_found() {
let html = "<html><body></body></html>";
let result = extract_search_results(html, "nonexistent");
assert!(result.contains("not found"));
assert!(result.contains("nonexistent"));
}
#[test]
fn test_is_item_fallback_page_parent_type_fallback() {
let html = "<html><body><section id=\"main-content\"><h1>Enum serde_json::Value</h1><p>An enum.</p></section></body></html>";
assert!(is_item_fallback_page(html, "Value::is_null"));
let result = extract_search_results(html, "Value::is_null");
assert!(
result.contains("No dedicated documentation page was found"),
"parent fallback note missing: {result}"
);
}
#[test]
fn test_is_item_fallback_page_direct_hit_not_flagged() {
let html = "<html><body><section id=\"main-content\"><h1>Trait serde::Serialize</h1><p>A trait.</p></section></body></html>";
assert!(!is_item_fallback_page(html, "serde::Serialize"));
assert!(!is_item_fallback_page(html, "Serialize"));
let fn_html = "<html><body><section id=\"main-content\"><h1>Function tokio::task::spawn</h1></section></body></html>";
assert!(!is_item_fallback_page(fn_html, "tokio::spawn"));
}
#[test]
fn test_is_item_fallback_page_crate_overview_fallback() {
let html = "<html><body><section id=\"main-content\"><h1>Crate serde</h1><p>Docs.</p></section></body></html>";
assert!(is_item_fallback_page(html, "DoesNotExist"));
}
#[test]
fn test_is_item_fallback_page_no_heading_does_not_warn() {
let html = "<html><body><section id=\"main-content\"><p>No heading here.</p></section></body></html>";
assert!(!is_item_fallback_page(html, "Foo::bar"));
}
#[test]
fn test_heading_contains_identifier_is_token_exact() {
assert!(!heading_contains_identifier("Struct this::That", "is"));
assert!(heading_contains_identifier(
"Struct serde_json::Value",
"Value"
));
assert!(heading_contains_identifier("Method is_null", "is_null"));
}
#[test]
fn test_clean_html_removes_link_tags() {
let html = r#"<html><head><link rel="stylesheet" href="test.css"></head><body>Hello</body></html>"#;
let cleaned = clean_html(html);
assert!(
!cleaned.contains("link"),
"link tag should be removed, got: {cleaned}"
);
assert!(
!cleaned.contains("stylesheet"),
"stylesheet should be removed, got: {cleaned}"
);
assert!(
cleaned.contains("Hello"),
"Body content should remain, got: {cleaned}"
);
}
#[test]
fn test_clean_html_removes_meta_tags() {
let html = r#"<html><head><meta charset="utf-8"></head><body>Content</body></html>"#;
let cleaned = clean_html(html);
assert!(
!cleaned.contains("meta"),
"meta tag should be removed, got: {cleaned}"
);
assert!(
cleaned.contains("Content"),
"Body content should remain, got: {cleaned}"
);
}
#[test]
fn test_relative_link_regex() {
let re = &RELATIVE_LINK_REGEX;
assert!(re.is_match("[module](module/index.html)"));
assert!(re.is_match("[struct](struct.Struct.html)"));
assert!(re.is_match("[tokio](../index.html)"));
assert!(re.is_match("[crate](./index.html)"));
assert!(re.is_match("[root](/serde/index.html)"));
assert!(re.is_match("[tutorial](_derive/_tutorial/index.html)"));
assert!(re.is_match("[v2](2/index.html)"));
assert!(!re.is_match("[Section](#section)")); assert!(
!re.is_match("[External](https://example.com)"),
"Should not match external URLs"
); }
#[test]
fn test_clean_markdown_keeps_external_html_links() {
let md = "See the [Guide](https://example.com/book/ch01.html) for details.";
let out = clean_markdown(md);
assert!(
out.contains("[Guide](https://example.com/book/ch01.html)"),
"external link should be preserved, got: {out}"
);
}
#[test]
fn test_clean_markdown_relative_links_keep_text() {
let md =
"Derive [tutorial](_derive/_tutorial/index.html) and [reference](_derive/index.html).";
let out = clean_markdown(md);
assert!(!out.contains(".html"), "relative link survived: {out}");
assert!(!out.contains("_derive"), "relative target survived: {out}");
assert!(
out.contains("Derive tutorial and reference."),
"text not kept: {out}"
);
}
#[test]
fn test_clean_markdown_relative_link_with_bracketed_label() {
let md = concat!(
"Use [`#[tokio::main]`](attr.main.html) and the slice ",
"[`[u8]`](primitive.slice.html) plus [Foo](struct.Foo.html)."
);
let out = clean_markdown(md);
assert!(!out.contains(".html"), "relative link survived: {out}");
assert!(
!out.contains("](attr"),
"bracketed-label link survived: {out}"
);
assert!(
out.contains("`#[tokio::main]`"),
"attribute label text dropped: {out}"
);
assert!(out.contains("`[u8]`"), "slice label text dropped: {out}");
assert!(out.contains("Foo"), "plain label text dropped: {out}");
}
#[test]
fn test_negative_impl_trait_not_rendered_as_image() {
let input = concat!(
"### impl<T> ![Freeze]",
"(https://doc.rust-lang.org/nightly/core/marker/trait.Freeze.html)",
" for Mutex<T>\n"
);
let md = clean_markdown(input);
assert!(
md.contains(r"\![Freeze]"),
"negative-impl marker not escaped: {md:?}"
);
assert!(
!md.contains("> ![Freeze]"),
"unescaped image syntax survived: {md:?}"
);
}
#[test]
fn test_clean_markdown_removes_old_rustdoc_artifacts() {
let md = concat!(
"Crate [serde]() [ [\u{2212}] ](javascript:void(0)) ",
"[[src]](../src/serde/lib.rs.html#9-267) [\u{24d8}](#)\n\nReal content ",
"[External](https://serde.rs/) [Quick start](#quick-start)."
);
let out = clean_markdown(md);
assert!(!out.contains("javascript:"), "js link leaked: {out}");
assert!(
!out.contains("src/serde/lib.rs.html"),
"src link leaked: {out}"
);
assert!(!out.contains("[[src]]"), "src label leaked: {out}");
assert!(!out.contains("]()"), "empty link leaked: {out}");
assert!(out.contains("serde"));
assert!(out.contains("Real content"));
assert!(out.contains("https://serde.rs/"));
assert!(!out.contains("(#)"), "fragment toggle leaked: {out}");
assert!(out.contains("#quick-start"), "real anchor dropped: {out}");
}
#[test]
fn test_clean_markdown_keeps_named_fragment_link_text() {
let md = "Crate [serde](#) [ⓘ](#)\n\nbody";
let out = clean_markdown(md);
assert!(out.contains("Crate serde"), "crate name dropped: {out}");
assert!(!out.contains("(#)"), "fragment link syntax leaked: {out}");
assert!(!out.contains("ⓘ"), "symbol toggle leaked: {out}");
}
#[test]
fn test_clean_markdown_drops_relative_read_more_keeps_absolute() {
let md = "Returns a duplicate of the value. [Read more](../clone/trait.Clone.html#tymethod.clone)";
let out = clean_markdown(md);
assert_eq!(
out.trim(),
"Returns a duplicate of the value.",
"relative Read more affordance not dropped cleanly: {out:?}"
);
let md2 = "Formats the value. [Read more](https://doc.rust-lang.org/core/fmt/trait.Debug.html#tymethod.fmt)";
let out2 = clean_markdown(md2);
assert!(
out2.contains(
"[Read more](https://doc.rust-lang.org/core/fmt/trait.Debug.html#tymethod.fmt)"
),
"absolute Read more link wrongly dropped: {out2:?}"
);
}
#[test]
fn test_clean_markdown_downgrades_rustdoc_item_anchors() {
let md = concat!(
"fn [parse](#method.parse)() -> Box and ",
"[`Error`](#associatedtype.Error) plus ",
"[here](#impl-Clone-for-Foo). See [Quick start](#quick-start)."
);
let out = clean_markdown(md);
assert!(
!out.contains("#method.parse"),
"method anchor survived: {out}"
);
assert!(
!out.contains("#associatedtype.Error"),
"assoc-type anchor survived: {out}"
);
assert!(!out.contains("#impl-"), "impl anchor survived: {out}");
assert!(out.contains("fn parse()"), "method label dropped: {out}");
assert!(out.contains("`Error`"), "assoc-type label dropped: {out}");
assert!(out.contains("here"), "impl label dropped: {out}");
assert!(
out.contains("[Quick start](#quick-start)"),
"section anchor wrongly downgraded: {out}"
);
}
#[test]
fn test_clean_markdown_removes_stray_middot_line() {
let md = "Crate serde\n==========\n\n\u{00b7}\n\nSerde is a framework.";
let out = clean_markdown(md);
assert!(
!out.contains("\n\u{00b7}\n"),
"stray middot line leaked: {out:?}"
);
assert!(out.contains("Crate serde"), "heading dropped: {out}");
assert!(out.contains("Serde is a framework."), "body dropped: {out}");
let inline = clean_markdown("a \u{00b7} b");
assert!(
inline.contains("\u{00b7}"),
"inline middot wrongly dropped: {inline}"
);
}
#[test]
fn test_clean_markdown_strips_trailing_middot_and_nbsp() {
let md = "Struct HashMap\u{00a0} \n==========\n\n1.0.0 \u{00b7}\n\nBody.";
let out = clean_markdown(md);
assert!(
out.contains("Struct HashMap\n"),
"trailing nbsp not trimmed from heading: {out:?}"
);
assert!(
out.contains("1.0.0\n") || out.ends_with("1.0.0\n\nBody."),
"trailing middot not stripped: {out:?}"
);
assert!(
!out.contains("1.0.0 \u{00b7}"),
"orphan middot survived: {out:?}"
);
assert!(
clean_markdown("a \u{00b7} b").contains('\u{00b7}'),
"inline middot wrongly dropped"
);
}
#[test]
fn test_clean_markdown_removes_breadcrumb_colon_lines() {
let md = "## Documentation: spawn
::
Function spawn
let x = S::Ok;";
let out = clean_markdown(md);
assert!(!out.contains("\n::\n"), "stray colon line leaked: {out}");
assert!(
out.contains("S::Ok"),
"inline path separator dropped: {out}"
);
assert!(out.contains("Function spawn"));
}
#[test]
fn test_clean_markdown_preserves_content() {
let markdown = r"# Dioxus
## At a glance
Dioxus is a framework for building cross-platform apps.
## Quick start
To get started with Dioxus:
```
cargo install dioxus-cli
```
[External Link](https://dioxuslabs.com)
[Anchor](#quick-start)
";
let cleaned = clean_markdown(markdown);
assert!(cleaned.contains("Dioxus is a framework"));
assert!(cleaned.contains("At a glance"));
assert!(cleaned.contains("Quick start"));
assert!(cleaned.contains("cargo install"));
assert!(
cleaned.contains("[External Link](https://dioxuslabs.com)"),
"Should preserve external links"
);
assert!(
cleaned.contains("[Anchor](#quick-start)"),
"Should preserve anchor links"
);
}
#[test]
fn test_extract_documentation_single_pass_optimization() {
let html = r#"
<!DOCTYPE html>
<html>
<head><title>Test Crate</title></head>
<body>
<nav>Navigation content</nav>
<section id="main-content">
<h1>Test Crate</h1>
<p>This is the main documentation.</p>
<script>console.log('test');</script>
<div class="docblock">
<p>Docblock content here.</p>
</div>
</section>
<footer>Footer content</footer>
</body>
</html>
"#;
let docs = extract_documentation(html);
assert!(docs.contains("Test Crate"), "Should contain title");
assert!(
docs.contains("main documentation"),
"Should contain main content"
);
assert!(
docs.contains("Docblock content"),
"Should preserve docblock"
);
assert!(!docs.contains("Navigation content"), "Should remove nav");
assert!(!docs.contains("Footer content"), "Should remove footer");
assert!(!docs.contains("console.log"), "Should remove script");
}
#[test]
fn test_extract_search_results_single_pass_optimization() {
let html = r#"
<!DOCTYPE html>
<html>
<body>
<section id="main-content">
<h1>serde::Serialize</h1>
<pre><code>pub trait Serialize { }</code></pre>
<p>Serialize trait documentation.</p>
</section>
<nav>Sidebar</nav>
</body>
</html>
"#;
let result = extract_search_results(html, "serde::Serialize");
assert!(result.contains("Documentation"));
assert!(result.contains("serde::Serialize"));
assert!(result.contains("Serialize trait"));
assert!(!result.contains("Sidebar"));
}
#[test]
fn test_clean_html_multiple_skip_tags() {
let html = r"
<html>
<head>
<style>.test { color: red; }</style>
<script>var x = 1;</script>
</head>
<body>
<nav>Navigation</nav>
<article>
<h1>Title</h1>
<p>Content with <script>inline script</script> removed.</p>
<footer>Article footer</footer>
</article>
<footer>Page footer</footer>
</body>
</html>
";
let cleaned = clean_html(html);
assert!(cleaned.contains("Title"));
assert!(cleaned.contains("Content"));
assert!(!cleaned.contains("style"), "Should remove style tags");
assert!(!cleaned.contains("script"), "Should remove script tags");
assert!(!cleaned.contains("Navigation"), "Should remove nav");
assert!(!cleaned.contains("footer"), "Should remove footer");
assert!(!cleaned.contains(".test"), "Should remove CSS content");
assert!(!cleaned.contains("var x"), "Should remove JS content");
}
#[test]
fn test_cached_selectors_all_tag_types() {
let test_cases = [
(
"<script>alert('test')</script><p>Content</p>",
"script",
"Content",
),
("<style>.x{}</style><p>Content</p>", "style", "Content"),
(
"<noscript>Enable JS</noscript><p>Content</p>",
"noscript",
"Content",
),
(
"<iframe src=\"x\"></iframe><p>Content</p>",
"iframe",
"Content",
),
("<nav><a>Link</a></nav><p>Content</p>", "nav", "Content"),
("<header>Head</header><p>Content</p>", "header", "Content"),
("<footer>Foot</footer><p>Content</p>", "footer", "Content"),
("<aside>Sidebar</aside><p>Content</p>", "aside", "Content"),
("<button>Click</button><p>Content</p>", "button", "Content"),
];
for (html, tag_to_remove, expected_content) in test_cases {
let cleaned = clean_html(html);
assert!(
!cleaned.contains(tag_to_remove),
"Should remove {tag_to_remove} tag"
);
assert!(
cleaned.contains(expected_content),
"Should preserve {expected_content}"
);
}
}
}