use std::borrow::Cow;
use crate::entities_data::{LABELS, NODES, NamedNode, VALUE_DATA, VALUES};
#[inline]
pub fn decode(input: &str, attribute: bool) -> Cow<'_, str> {
let bytes = input.as_bytes();
let first = if bytes.len() < 16 {
bytes
.iter()
.position(|&byte| matches!(byte, b'&' | b'\r') || (attribute && byte == 0))
} else if attribute {
memchr::memchr3(b'&', b'\r', 0, bytes)
} else {
memchr::memchr2(b'&', b'\r', bytes)
};
let Some(first) = first else {
return Cow::Borrowed(input);
};
decode_from(input, attribute, first)
}
#[inline(never)]
pub(crate) fn decode_from(input: &str, attribute: bool, mut cursor: usize) -> Cow<'_, str> {
let bytes = input.as_bytes();
let mut output = None;
let mut unchanged = 0;
while cursor < bytes.len() {
let (next, replacement) = match bytes[cursor] {
b'&' => {
let Some((consumed, replacement)) = reference(&input[cursor + 1..], attribute)
else {
cursor += 1;
continue;
};
(cursor + consumed + 1, replacement)
}
b'\r' => (
cursor + 1 + usize::from(bytes.get(cursor + 1) == Some(&b'\n')),
Replacement::Character('\n'),
),
0 if attribute => (cursor + 1, Replacement::Character('\u{fffd}')),
_ => {
cursor += if attribute {
memchr::memchr3(b'&', b'\r', 0, &bytes[cursor..])
} else {
memchr::memchr2(b'&', b'\r', &bytes[cursor..])
}
.unwrap_or(bytes.len() - cursor);
continue;
}
};
let output = output.get_or_insert_with(|| String::with_capacity(input.len()));
output.push_str(&input[unchanged..cursor]);
match replacement {
Replacement::Character(character) => output.push(character),
Replacement::Named(text) => output.push_str(text),
}
cursor = next;
unchanged = next;
}
if let Some(mut output) = output {
output.push_str(&input[unchanged..]);
if output.capacity() > 64 && output.capacity() > output.len().saturating_mul(2) {
output.shrink_to_fit();
}
Cow::Owned(output)
} else {
Cow::Borrowed(input)
}
}
pub fn normalize(input: &str) -> Cow<'_, str> {
let Some(first) = memchr::memchr(b'\r', input.as_bytes()) else {
return Cow::Borrowed(input);
};
let mut output = String::with_capacity(input.len());
output.push_str(&input[..first]);
let mut rest = &input[first..];
while let Some(offset) = memchr::memchr(b'\r', rest.as_bytes()) {
output.push_str(&rest[..offset]);
output.push('\n');
rest = &rest[offset + 1..];
if let Some(suffix) = rest.strip_prefix('\n') {
rest = suffix;
}
}
output.push_str(rest);
Cow::Owned(output)
}
enum Replacement {
Character(char),
Named(&'static str),
}
fn reference(input: &str, attribute: bool) -> Option<(usize, Replacement)> {
let bytes = input.as_bytes();
if bytes.first() == Some(&b'#') {
let hex = matches!(bytes.get(1), Some(b'x' | b'X'));
let start = if hex { 2 } else { 1 };
let radix = if hex { 16 } else { 10 };
let mut end = start;
let mut number = 0u32;
while let Some(digit) = bytes
.get(end)
.and_then(|byte| char::from(*byte).to_digit(radix))
{
number = number.saturating_mul(radix).saturating_add(digit);
end += 1;
}
if end == start {
return None;
}
if bytes.get(end) == Some(&b';') {
end += 1;
}
return Some((end, Replacement::Character(numeric(number))));
}
let common = match bytes {
[b'g', b't', b';', ..] => Some((3, ">")),
[b'l', b't', b';', ..] => Some((3, "<")),
[b'a', b'm', b'p', b';', ..] => Some((4, "&")),
[b'q', b'u', b'o', b't', b';', ..] => Some((5, "\"")),
[b'a', b'p', b'o', b's', b';', ..] => Some((5, "'")),
_ => None,
};
if let Some((length, value)) = common {
return Some((length, Replacement::Named(value)));
}
let initial = match bytes.first()? {
b'A'..=b'Z' => usize::from(bytes[0] - b'A'),
b'a'..=b'z' => usize::from(bytes[0] - b'a') + 26,
_ => return None,
};
let mut node = &NODES[initial];
let mut consumed = 0;
let mut matched = None;
loop {
let &NamedNode(label, children, value, label_len, child_count) = node;
let label = usize::from(label);
if !bytes[consumed..].starts_with(&LABELS[label..label + usize::from(label_len)]) {
break;
}
consumed += usize::from(label_len);
if value != 0 {
if bytes.get(consumed) == Some(&b';') {
matched = Some((consumed + 1, value));
break;
}
if value & 0x8000 != 0
&& (!attribute
|| !bytes
.get(consumed)
.is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'='))
{
matched = Some((consumed, value));
}
}
let Some(&next) = bytes.get(consumed) else {
break;
};
let children = usize::from(children);
let children = &NODES[children..children + usize::from(child_count)];
let Ok(index) = children.binary_search_by_key(&next, |node| LABELS[usize::from(node.0)])
else {
break;
};
node = &children[index];
}
matched.map(|(length, value)| {
let (start, len) = VALUES[usize::from((value & 0x7fff) - 1)];
let start = usize::from(start);
(
length,
Replacement::Named(&VALUE_DATA[start..start + usize::from(len)]),
)
})
}
fn numeric(number: u32) -> char {
const C1: [u32; 32] = [
0x20ac, 0x81, 0x201a, 0x192, 0x201e, 0x2026, 0x2020, 0x2021, 0x2c6, 0x2030, 0x160, 0x2039,
0x152, 0x8d, 0x17d, 0x8f, 0x90, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014,
0x2dc, 0x2122, 0x161, 0x203a, 0x153, 0x9d, 0x17e, 0x178,
];
let number = match number {
0 => 0xfffd,
0x80..=0x9f => C1[(number - 0x80) as usize],
number => number,
};
char::from_u32(number).unwrap_or('\u{fffd}')
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entities_data::NAMED;
#[test]
fn named_references_follow_attribute_context() {
assert_eq!(
decode("¬it; &=1 & ≂̸", false),
"¬it; &=1 & ≂\u{338}"
);
assert_eq!(
decode("¬it; &=1 & ≂̸", true),
"¬it; &=1 & ≂\u{338}"
);
assert_eq!(decode("&lt;", true), "<");
}
#[test]
fn unterminated_named_references_choose_the_longest_legacy_prefix() {
for &(name, _) in NAMED {
let name = name.trim_end_matches(';');
let source = format!("&{name}");
let legacy = NAMED
.iter()
.filter(|(legacy, _)| !legacy.ends_with(';') && name.starts_with(legacy))
.max_by_key(|(legacy, _)| legacy.len());
let expected = legacy.map_or_else(
|| source.clone(),
|(legacy, value)| format!("{value}{}", &name[legacy.len()..]),
);
assert_eq!(decode(&source, false), expected, "{source}");
let expected = legacy
.filter(|(legacy, _)| legacy.len() == name.len())
.map_or(source.as_str(), |(_, value)| *value);
assert_eq!(decode(&source, true), expected, "{source}");
}
}
#[test]
fn unchanged_ampersands_borrow_the_input() {
for source in [
"a & b",
"?a=1&unknown=2",
"&unknown;",
"&#x;",
"é &unknown; 🦀",
] {
for attribute in [false, true] {
assert!(
matches!(decode(source, attribute), Cow::Borrowed(value) if value == source)
);
}
}
assert!(matches!(decode("¬it;", true), Cow::Borrowed("¬it;")));
assert_eq!(
decode("&unknown; then & and &unknown;", false),
"&unknown; then & and &unknown;"
);
}
#[test]
fn numeric_references_replace_invalid_scalars() {
assert_eq!(
decode("� � � € 🦀", false),
"� � � € 🦀"
);
assert_eq!(decode("�", true), "�");
assert_eq!(decode("&#x; &#; A=", true), "&#x; &#; A=");
}
#[test]
fn compressed_references_do_not_retain_input_sized_buffers() {
for (source, expected) in [
(format!("&#{}65;", "0".repeat(1024 * 1024)), "A".to_owned()),
(format!("&#x{}41;", "0".repeat(1024 * 1024)), "A".to_owned()),
("A".repeat(8192), "A".repeat(8192)),
(
"∳".repeat(8192),
"∳".repeat(8192),
),
] {
for attribute in [false, true] {
let Cow::Owned(decoded) = decode(&source, attribute) else {
panic!("decoded references must be owned");
};
assert_eq!(decoded, expected);
assert!(decoded.capacity() <= 2 * decoded.len());
}
}
}
#[test]
fn newlines_and_null_are_contextual() {
assert_eq!(decode("a\r\nb\rc\0", true), "a\nb\nc�");
assert_eq!(decode("a\r\nb\rc\0", false), "a\nb\nc\0");
assert_eq!(normalize("\r\n\r©\0"), "\n\n©\0");
assert!(matches!(decode("plain 🦀 text", true), Cow::Borrowed(_)));
}
#[test]
fn every_named_reference_decodes() {
assert_eq!(NAMED.len(), 2231);
for &(name, expected) in NAMED {
for attribute in [false, true] {
assert_eq!(decode(&format!("&{name}"), attribute), expected, "{name}");
}
if !name.ends_with(';') {
let terminated = format!("{name};");
assert!(
NAMED.contains(&(terminated.as_str(), expected)),
"missing terminated spelling for {name}"
);
}
}
}
}