use crate::entities_table::{ENTITIES, ENTITY_DISPLACEMENTS, ENTITY_SEED, LONGEST_ENTITY_NAME};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct NamedCharacterReference {
name: &'static str,
characters: &'static str,
}
impl NamedCharacterReference {
#[inline]
#[must_use]
pub const fn name(self) -> &'static str {
self.name
}
#[inline]
#[must_use]
pub const fn characters(self) -> &'static str {
self.characters
}
}
#[inline]
#[must_use]
pub fn match_named_character_reference(input: &str) -> Option<NamedCharacterReference> {
let run_len = input
.bytes()
.position(|b| !b.is_ascii_alphanumeric())
.unwrap_or(input.len());
if input.as_bytes().get(run_len) == Some(&b';')
&& let Some(reference) = entity(&input[..=run_len])
{
return Some(reference);
}
(1..=run_len.min(LONGEST_ENTITY_NAME))
.rev()
.find_map(|len| entity(&input[..len]))
}
#[inline]
fn entity(name: &str) -> Option<NamedCharacterReference> {
let (name_slot, characters) = *ENTITIES.get(entity_slot(entity_hash(name.as_bytes())))?;
(name_slot == name).then_some(NamedCharacterReference {
name: name_slot,
characters,
})
}
#[inline]
pub(crate) fn entity_slot(hash: u64) -> usize {
let (bucket, f1, f2) = entity_hash_parts(hash);
let Some(&(d1, d2)) = ENTITY_DISPLACEMENTS.get(bucket as usize % ENTITY_DISPLACEMENTS.len())
else {
return usize::MAX;
};
(d2.wrapping_add(f1.wrapping_mul(d1)).wrapping_add(f2) as usize) % ENTITIES.len()
}
#[inline]
pub(crate) const fn entity_hash_parts(hash: u64) -> (u32, u32, u32) {
(
(hash >> 43) as u32,
((hash >> 22) & 0x1F_FFFF) as u32,
(hash & 0x3F_FFFF) as u32,
)
}
#[inline]
pub(crate) fn entity_hash(name: &[u8]) -> u64 {
entity_hash_seeded(name, ENTITY_SEED)
}
#[inline]
pub(crate) fn entity_hash_seeded(name: &[u8], seed: u64) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325 ^ seed;
for &byte in name {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash ^= hash >> 30;
hash = hash.wrapping_mul(0xbf58_476d_1ce4_e5b9);
hash ^= hash >> 27;
hash = hash.wrapping_mul(0x94d0_49bb_1331_11eb);
hash ^ (hash >> 31)
}
#[cfg(test)]
mod tests {
use super::*;
fn reference(name: &'static str, characters: &'static str) -> NamedCharacterReference {
NamedCharacterReference { name, characters }
}
#[test]
fn test_entity_table_shape() {
let mut two_character = 0;
for &(name, characters) in &ENTITIES {
let bare = name.strip_suffix(';').unwrap_or(name);
assert!(
!bare.is_empty() && bare.bytes().all(|b| b.is_ascii_alphanumeric()),
"{name:?} is not an ASCII name run plus an optional semicolon"
);
match characters.chars().count() {
1 => {}
2 => two_character += 1,
n => panic!("{name:?} stands for {n} characters"),
}
}
assert_eq!(ENTITIES.len(), 2231);
assert_eq!(two_character, 93);
}
#[test]
fn test_legacy_names_have_semicolon_twins() {
for &(name, characters) in &ENTITIES {
if !name.ends_with(';') {
assert_eq!(
entity(&format!("{name};")).map(NamedCharacterReference::characters),
Some(characters),
"{name:?}"
);
}
}
}
#[test]
fn test_entity_table_lookup() {
for (slot, &(name, characters)) in ENTITIES.iter().enumerate() {
assert_eq!(entity_slot(entity_hash(name.as_bytes())), slot, "{name:?}");
assert_eq!(entity(name), Some(reference(name, characters)), "{name:?}");
}
for absent in ["", "amp;;", "AMp;", "nbsp;x", "notin", "&", "\u{e9}"] {
assert_eq!(entity(absent), None, "{absent:?}");
}
}
#[test]
fn test_longest_entity_name() {
assert_eq!(
ENTITIES.iter().map(|(name, _)| name.len()).max(),
Some(LONGEST_ENTITY_NAME)
);
let longest = match_named_character_reference("CounterClockwiseContourIntegral;");
assert_eq!(
longest,
Some(reference("CounterClockwiseContourIntegral;", "\u{2233}"))
);
assert_eq!(longest.map(|r| r.name().len()), Some(LONGEST_ENTITY_NAME));
}
#[test]
fn test_match_every_name() {
for &(name, characters) in &ENTITIES {
assert_eq!(
match_named_character_reference(name),
Some(reference(name, characters)),
"{name:?}"
);
assert_eq!(
match_named_character_reference(&format!("{name} tail")),
Some(reference(name, characters)),
"{name:?}"
);
}
assert_eq!(
match_named_character_reference("COPYRIGHT"),
Some(reference("COPY", "\u{a9}"))
);
assert_eq!(
match_named_character_reference("notit;"),
Some(reference("not", "\u{ac}"))
);
assert_eq!(
match_named_character_reference("notin;"),
Some(reference("notin;", "\u{2209}"))
);
}
#[test]
fn test_match_misses() {
for input in ["", ";", "#65;", " amp;", "xyz;", "\u{e9}amp;", "AMp;"] {
assert_eq!(match_named_character_reference(input), None, "{input:?}");
}
assert_eq!(
match_named_character_reference("amp\u{4e2d}"),
Some(reference("amp", "&"))
);
assert_eq!(match_named_character_reference("a\u{4e2d}pos;"), None);
}
#[test]
fn test_name_is_the_consumed_prefix() {
for input in [
"amp;",
"amp",
"amp tail",
"amp\u{4e2d}",
"ampamp;",
"COPYRIGHT",
"notit;",
"notin;x",
"\u{e9}",
] {
if let Some(matched) = match_named_character_reference(input) {
assert!(input.starts_with(matched.name()), "{input:?}");
}
}
}
#[test]
fn test_long_name_run() {
assert_eq!(match_named_character_reference(&"a".repeat(10_000)), None);
assert_eq!(
match_named_character_reference(&format!("amp{}", "a".repeat(10_000))),
Some(reference("amp", "&"))
);
}
}