pub fn decode(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some((before, after_amp)) = rest.split_once('&') {
out.push_str(before);
match after_amp.split_once(';') {
Some((token, after_semi)) => match decode_token(token) {
Some(ch) => {
out.push(ch);
rest = after_semi;
}
None => {
out.push('&');
rest = after_amp;
}
},
None => {
out.push('&');
out.push_str(after_amp);
rest = "";
}
}
}
out.push_str(rest);
out
}
fn decode_token(token: &str) -> Option<char> {
if let Some(rest) = token.strip_prefix('#') {
let n: u32 = if let Some(hex) = rest.strip_prefix(['x', 'X']) {
u32::from_str_radix(hex, 16).ok()?
} else {
rest.parse().ok()?
};
return char::from_u32(n);
}
match token {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some('\u{a0}'),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_numeric_and_named_entities() {
assert_eq!(decode("Finding China’s Voice"), "Finding China’s Voice");
assert_eq!(decode("AT&T"), "AT&T");
assert_eq!(decode("a—b"), "a—b");
assert_eq!(decode("no entities"), "no entities");
}
#[test]
fn text_that_was_never_encoded_survives_unchanged() {
assert_eq!(decode("Cats & dogs"), "Cats & dogs");
assert_eq!(decode("a¬anentity;b"), "a¬anentity;b");
assert_eq!(decode("50% & rising"), "50% & rising");
}
#[test]
fn a_numeric_entity_is_decoded_whole() {
assert_eq!(decode("Jo's photos/a.jpg"), "Jo's photos/a.jpg");
}
}