#[must_use]
pub fn decode_html_entities(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c != '&' {
out.push(c);
continue;
}
let mut buf = String::new();
let mut ended = false;
while let Some(&next) = chars.peek() {
if next == ';' {
chars.next();
ended = true;
break;
}
if buf.len() > 8 {
break;
}
buf.push(next);
chars.next();
}
if !ended {
out.push('&');
out.push_str(&buf);
continue;
}
match buf.as_str() {
"amp" => out.push('&'),
"lt" => out.push('<'),
"gt" => out.push('>'),
"quot" => out.push('"'),
"apos" => out.push('\''),
"nbsp" => out.push('\u{00A0}'),
other if other.starts_with('#') => {
let body = &other[1..];
let code =
if let Some(hex) = body.strip_prefix('x').or_else(|| body.strip_prefix('X')) {
u32::from_str_radix(hex, 16).ok()
} else {
body.parse::<u32>().ok()
};
if let Some(code) = code {
if let Some(ch) = char::from_u32(code) {
out.push(ch);
}
}
}
_ => {
out.push('&');
out.push_str(&buf);
out.push(';');
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_named_entities() {
assert_eq!(decode_html_entities("a & b"), "a & b");
assert_eq!(decode_html_entities("<tag>"), "<tag>");
assert_eq!(decode_html_entities(""x""), "\"x\"");
assert_eq!(decode_html_entities("'x'"), "'x'");
}
#[test]
fn decodes_numeric_entities() {
assert_eq!(decode_html_entities("A"), "A");
assert_eq!(decode_html_entities("A"), "A");
}
#[test]
fn passes_through_unknown() {
assert_eq!(decode_html_entities("&unknown;"), "&unknown;");
}
#[test]
fn handles_orphan_ampersand() {
assert_eq!(decode_html_entities("a & b"), "a & b");
}
}