use super::document::Normalize;
use super::types::caps;
use std::collections::BTreeSet;
use unicode_normalization::UnicodeNormalization;
#[derive(Debug, Clone, Default)]
pub struct EmojiCodes(pub BTreeSet<String>);
impl EmojiCodes {
pub fn from_policy<'a>(codes: impl IntoIterator<Item = &'a String>) -> Self {
EmojiCodes(codes.into_iter().cloned().collect())
}
fn resolves(&self, name: &str) -> bool {
self.0.contains(name)
}
}
fn is_default_ignorable(c: char) -> bool {
matches!(c as u32,
0x00AD | 0x034F | 0x061C | 0x115F..=0x1160 | 0x17B4..=0x17B5 | 0x180B..=0x180F |
0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x206F | 0x3164 | 0xFE00..=0xFE0F |
0xFEFF | 0xFFA0 | 0x1D173..=0x1D17A | 0xE0000..=0xE0FFF)
}
fn is_skeleton_alpha(c: char) -> bool {
c.is_alphanumeric() && !c.is_numeric()
}
pub fn fold(text: &str) -> String {
let nfc: String = text.nfc().collect();
let folded: String = nfc
.chars()
.filter(|c| !is_default_ignorable(*c))
.flat_map(|c| c.to_lowercase())
.collect();
folded.nfc().collect()
}
fn shortcode_at(s: &str, open: usize) -> Option<(&str, usize)> {
let rest = &s[open + 1..];
let close = rest.find(':')?;
if close == 0 {
return None;
}
let name = &rest[..close];
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '+') {
return None;
}
Some((name, open + 1 + close + 1))
}
pub fn skeleton(text: &str, codes: &EmojiCodes) -> String {
let mut kept = String::with_capacity(text.len());
let mut i = 0usize;
let bytes = text.as_bytes();
while i < text.len() {
if bytes[i] == b':' {
if let Some((name, next)) = shortcode_at(text, i) {
if !codes.resolves(name) && name.chars().count() >= caps::MIN_SKELETON_LEN {
kept.push_str(name);
}
i = next;
continue;
}
}
let ch = text[i..].chars().next().expect("byte index is a char boundary");
kept.push(ch);
i += ch.len_utf8();
}
fold(&kept).chars().filter(|c| is_skeleton_alpha(*c)).collect()
}
pub fn apply(text: &str, n: Normalize, codes: &EmojiCodes) -> String {
match n {
Normalize::None => text.to_string(),
Normalize::Fold => fold(text),
Normalize::Skeleton => skeleton(text, codes),
Normalize::Confusables => fold(text),
}
}
pub fn is_available(n: Normalize) -> bool {
!matches!(n, Normalize::Confusables)
}
pub fn is_token_char(c: char) -> bool {
c.is_alphanumeric()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fold_is_case_and_form_insensitive_and_strips_invisibles() {
assert_eq!(fold("HELLO"), "hello");
assert_eq!(fold("café"), fold("cafe\u{0301}"));
assert_eq!(fold("s\u{200B}cam"), "scam");
assert_eq!(fold("ẞ"), fold("ß"));
}
#[test]
fn skeleton_drops_the_cheap_variations() {
let e = EmojiCodes::default();
assert_eq!(skeleton("Hello, World!", &e), skeleton("hello world", &e));
assert_eq!(skeleton("h3ll0 w0rld", &e), "hllwrld", "digits carry no weight");
assert_ne!(skeleton("hello world", &e), skeleton("goodbye world", &e));
}
#[test]
fn shortcode_resolution_has_exactly_three_outcomes() {
let known = EmojiCodes::from_policy([&"vector_logo".to_string()]);
assert_eq!(skeleton(":vector_logo:", &known), "");
assert_eq!(skeleton("nice :vector_logo: one", &known), skeleton("nice one", &known));
assert_eq!(skeleton(":fire:", &known), "");
assert_eq!(skeleton(":+1:", &known), "");
assert_eq!(skeleton(":buycheapcoinsnow:", &known), "buycheapcoinsnow");
assert_eq!(skeleton(":unlisted_pack_emoji:", &known), "unlistedpackemoji");
}
#[test]
fn a_lone_or_unclosed_colon_is_left_alone() {
let e = EmojiCodes::default();
assert_eq!(skeleton("ratio 3:1 today", &e), skeleton("ratio 3 1 today", &e));
assert_eq!(skeleton(":unclosed", &e), "unclosed");
assert_eq!(skeleton("::", &e), "");
}
#[test]
fn confusables_is_declared_but_not_yet_implemented() {
assert!(!is_available(Normalize::Confusables));
assert!(is_available(Normalize::Skeleton));
}
}