use std::sync::OnceLock;
use regex::Regex;
use unicode_normalization::UnicodeNormalization;
pub fn decode_html_entities(input: &str) -> String {
let mut out = input
.replace("&", "&")
.replace(""", "\"")
.replace("'", "'")
.replace("<", "<")
.replace(">", ">")
.replace(" ", " ");
static DEC: OnceLock<Regex> = OnceLock::new();
static HEX: OnceLock<Regex> = OnceLock::new();
let dec = DEC.get_or_init(|| Regex::new(r"&#(\d+);").expect("dec entity"));
let hex = HEX.get_or_init(|| Regex::new(r"&#x([0-9a-fA-F]+);").expect("hex entity"));
out = dec
.replace_all(&out, |caps: ®ex::Captures| {
caps.get(1)
.and_then(|m| m.as_str().parse::<u32>().ok())
.and_then(char::from_u32)
.map(|c| c.to_string())
.unwrap_or_default()
})
.into_owned();
out = hex
.replace_all(&out, |caps: ®ex::Captures| {
caps.get(1)
.and_then(|m| u32::from_str_radix(m.as_str(), 16).ok())
.and_then(char::from_u32)
.map(|c| c.to_string())
.unwrap_or_default()
})
.into_owned();
out
}
pub fn strip_html_tags(html: &str) -> String {
static TAG: OnceLock<Regex> = OnceLock::new();
let re = TAG.get_or_init(|| Regex::new(r"<[^>]+>").expect("tag regex"));
re.replace_all(html, "").into_owned()
}
pub fn normalize_field(raw: &str) -> String {
if raw.is_empty() {
return String::new();
}
let decoded = decode_html_entities(raw);
strip_html_tags(&decoded).replace('\u{00a0}', " ")
}
pub fn normalize_url_field(url: &str) -> String {
if url.is_empty() {
return String::new();
}
percent_unquote(url).replace(' ', "+")
}
fn percent_unquote(input: &str) -> String {
let mut bytes = Vec::new();
let b = input.as_bytes();
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
if let Ok(hex) = std::str::from_utf8(&b[i + 1..i + 3])
&& let Ok(byte) = u8::from_str_radix(hex, 16)
{
bytes.push(byte);
i += 3;
continue;
}
} else if b[i] == b'+' {
bytes.push(b' ');
i += 1;
continue;
}
bytes.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&bytes).into_owned()
}
pub fn clean_title(raw: &str) -> String {
normalize_field(raw).trim().to_string()
}
pub fn clean_snippet(raw: &str) -> String {
normalize_field(raw).trim().to_string()
}
pub fn fold_for_search(s: &str) -> String {
decode_html_entities(s)
.nfkd()
.filter(|c| !unicode_normalization::char::is_combining_mark(*c))
.collect::<String>()
.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fold_strips_accents() {
assert_eq!(fold_for_search("Raphaël"), "raphael");
assert_eq!(fold_for_search("café"), "cafe");
}
#[test]
fn decodes_numeric_entity_in_title() {
assert_eq!(normalize_field("Raphaël MANSUY"), "Raphaël MANSUY");
}
#[test]
fn normalize_field_strips_tags_like_python() {
assert_eq!(normalize_field("<strong>Rust</strong> lang"), "Rust lang");
}
#[test]
fn normalize_url_replaces_spaces() {
assert_eq!(
normalize_url_field("https://example.com/a%20b"),
"https://example.com/a+b"
);
}
}