use crate::entities::ENTITIES;
const MAX_NAME: usize = 32;
const MAX_LEGACY: usize = 6;
pub(crate) fn decode_into(out: &mut String, raw: &str, in_attr: bool) {
let bytes = raw.as_bytes();
let mut last = 0;
let mut i = 0;
while let Some(off) = memchr::memchr(b'&', &bytes[i..]) {
let amp = i + off;
out.push_str(&raw[last..amp]);
match reference(raw, amp + 1, in_attr) {
Some((a, b, end)) => {
out.push(a);
if b != '\0' {
out.push(b);
}
last = end;
i = end;
}
None => {
out.push('&');
last = amp + 1;
i = amp + 1;
}
}
}
out.push_str(&raw[last..]);
}
fn reference(raw: &str, start: usize, in_attr: bool) -> Option<(char, char, usize)> {
let bytes = raw.as_bytes();
match bytes.get(start) {
Some(b'#') => numeric(bytes, start + 1),
Some(c) if c.is_ascii_alphanumeric() => named(raw, start, in_attr),
_ => None,
}
}
fn numeric(bytes: &[u8], start: usize) -> Option<(char, char, usize)> {
let (hex, mut i) = match bytes.get(start) {
Some(b'x' | b'X') => (true, start + 1),
_ => (false, start),
};
let digits_start = i;
let mut value: u32 = 0;
while let Some(&c) = bytes.get(i) {
let d = match c {
b'0'..=b'9' => c - b'0',
b'a'..=b'f' if hex => c - b'a' + 10,
b'A'..=b'F' if hex => c - b'A' + 10,
_ => break,
};
value = value
.saturating_mul(if hex { 16 } else { 10 })
.saturating_add(d as u32);
i += 1;
}
if i == digits_start {
return None;
}
if bytes.get(i) == Some(&b';') {
i += 1;
}
Some((numeric_char(value), '\0', i))
}
fn numeric_char(value: u32) -> char {
const C1: [u32; 32] = [
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160,
0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013,
0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
];
let v = match value {
0x80..=0x9F => C1[(value - 0x80) as usize],
v => v,
};
if v == 0 {
return '\u{FFFD}';
}
char::from_u32(v).unwrap_or('\u{FFFD}')
}
fn named(raw: &str, start: usize, in_attr: bool) -> Option<(char, char, usize)> {
let bytes = raw.as_bytes();
let mut end = start;
while end < bytes.len() && end - start < MAX_NAME && bytes[end].is_ascii_alphanumeric() {
end += 1;
}
if bytes.get(end) == Some(&b';')
&& let Some((a, b)) = lookup(&raw[start..=end])
{
return Some((a, b, end + 1));
}
for len in (1..=(end - start).min(MAX_LEGACY)).rev() {
let Some((a, b)) = lookup(&raw[start..start + len]) else {
continue;
};
let next = bytes.get(start + len).copied();
if in_attr && next.is_some_and(|c| c.is_ascii_alphanumeric() || c == b'=') {
return None;
}
return Some((a, b, start + len));
}
None
}
static BUCKETS: [u16; 129] = {
let mut starts = [0u16; 129];
let mut b = 0;
let mut i = 0;
while b < 129 {
while i < ENTITIES.len() && (ENTITIES[i].0.as_bytes()[0] as usize) < b {
i += 1;
}
starts[b] = i as u16;
b += 1;
}
starts
};
fn lookup(name: &str) -> Option<(char, char)> {
let name = name.as_bytes();
let first = *name.first()? as usize;
if first >= 128 {
return None;
}
let bucket = &ENTITIES[BUCKETS[first] as usize..BUCKETS[first + 1] as usize];
bucket
.binary_search_by(|(n, _, _)| cmp_bytes(n.as_bytes(), name))
.ok()
.map(|i| (bucket[i].1, bucket[i].2))
}
#[inline]
fn cmp_bytes(a: &[u8], b: &[u8]) -> std::cmp::Ordering {
for (x, y) in a.iter().zip(b) {
if x != y {
return x.cmp(y);
}
}
a.len().cmp(&b.len())
}
#[cfg(test)]
mod tests {
fn text(s: &str) -> String {
let mut out = String::new();
super::decode_into(&mut out, s, false);
out
}
fn attr(s: &str) -> String {
let mut out = String::new();
super::decode_into(&mut out, s, true);
out
}
#[test]
fn bucketed_lookup_finds_every_entity() {
for (i, &(name, a, b)) in crate::entities::ENTITIES.iter().enumerate() {
assert_eq!(super::lookup(name), Some((a, b)), "{i} {name}");
}
assert_eq!(super::lookup("notanentity;"), None);
assert_eq!(super::lookup("é"), None);
}
#[test]
fn named_and_numeric() {
assert_eq!(text("a & b <c>"), "a & b <c>");
assert_eq!(text("©©©©"), "©©©©");
assert_eq!(text(" x"), "\u{a0}x");
assert_eq!(text("∾̳"), "\u{223e}\u{333}");
assert_eq!(
text("���€"),
"\u{FFFD}\u{FFFD}\u{FFFD}€"
);
assert_eq!(text("�"), "\u{FFFD}");
}
#[test]
fn not_references() {
assert_eq!(
text("AT&T & co &#; &#x; &unknown; &"),
"AT&T & co &#; &#x; &unknown; &"
);
assert_eq!(text("¬it; &"), "¬it; &");
}
#[test]
fn attribute_rules() {
assert_eq!(attr("?a=1©=2&lang=en"), "?a=1©=2&lang=en");
assert_eq!(attr("© 2024 &x"), "© 2024 &x");
assert_eq!(attr("&x"), "&x");
}
}