use std::collections::HashMap;
use serde::Deserialize;
#[derive(Clone, Debug, PartialEq, Deserialize)]
pub struct Emote {
pub name: String,
pub url: String,
pub provider: String,
#[serde(default)]
pub id: String,
#[serde(default)]
pub animated: bool,
#[serde(rename = "zeroWidth", default)]
pub zero_width: bool,
#[serde(skip)]
pub global: bool,
}
#[derive(Debug, Deserialize)]
pub struct EmoteSetResponse {
#[serde(default)]
pub emotes: Vec<Emote>,
}
#[derive(Clone, Debug, Default)]
pub struct EmoteSet {
map: HashMap<String, Emote>,
}
pub fn owner_list(v: &serde_json::Value, base: &str) -> Vec<Emote> {
let Some(rows) = v.get("emotes").and_then(|e| e.as_array()) else {
return Vec::new();
};
rows.iter()
.filter_map(|r| {
let s = |k: &str| r.get(k).and_then(|x| x.as_str()).filter(|x| !x.is_empty());
let name = s("name")?;
let url = s("url")?;
let url = if url.starts_with('/') && !url.starts_with("//") {
format!("{base}{url}")
} else if url.starts_with("https://") {
url.to_string()
} else {
return None;
};
Some(Emote {
name: name.to_string(),
url,
provider: s("provider").unwrap_or("upload").to_string(),
id: s("hash").unwrap_or_default().to_string(),
animated: false,
zero_width: false,
global: false,
})
})
.collect()
}
impl EmoteSet {
pub fn new() -> EmoteSet {
EmoteSet::default()
}
pub fn none() -> &'static EmoteSet {
static NONE: std::sync::OnceLock<EmoteSet> = std::sync::OnceLock::new();
NONE.get_or_init(EmoteSet::new)
}
pub fn from_list(list: impl IntoIterator<Item = Emote>) -> EmoteSet {
let mut set = EmoteSet::new();
for e in list {
set.map.insert(e.name.clone(), e);
}
set
}
pub fn from_global_list(list: impl IntoIterator<Item = Emote>) -> EmoteSet {
const WINNER_FIRST: [&str; 4] = ["7tv", "bttv", "ffz", "twitch"];
let rank = |e: &Emote| {
WINNER_FIRST
.iter()
.position(|p| *p == e.provider)
.unwrap_or(WINNER_FIRST.len())
};
let mut list: Vec<Emote> = list.into_iter().collect();
list.sort_by_key(|e| std::cmp::Reverse(rank(e)));
let mut set = EmoteSet::from_list(list);
set.mark_global();
set
}
pub fn merge(&mut self, other: EmoteSet) {
for (k, v) in other.map {
match self.map.get(&k) {
Some(cur) if cur.global && !v.global => {
self.map.insert(k, v);
}
Some(_) => {}
None => {
self.map.insert(k, v);
}
}
}
}
pub fn mark_global(&mut self) {
for e in self.map.values_mut() {
e.global = true;
}
}
pub fn get(&self, name: &str) -> Option<&Emote> {
self.map.get(name)
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.map.keys().map(String::as_str)
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Effect {
RotateL, RotateR, FlipX, FlipY, Wide, Tall, Cursed, Hyper, Party, Rainbow, Shake, Bounce, Jam, Slide, Arrive, Leave, Spin, }
const CODES: [(Effect, char); 17] = [
(Effect::RotateL, 'l'),
(Effect::RotateR, 'r'),
(Effect::FlipX, 'x'),
(Effect::FlipY, 'y'),
(Effect::Wide, 'w'),
(Effect::Tall, 't'),
(Effect::Cursed, 'c'),
(Effect::Hyper, 'H'),
(Effect::Party, 'p'),
(Effect::Rainbow, 'R'),
(Effect::Shake, 's'),
(Effect::Bounce, 'b'),
(Effect::Jam, 'j'),
(Effect::Slide, 'd'),
(Effect::Arrive, 'a'),
(Effect::Leave, 'e'),
(Effect::Spin, 'o'),
];
#[derive(Clone, Copy)]
enum Tok {
Fx(Effect),
Zero,
}
const TOKENS: [(&str, Tok); 25] = [
("w!", Tok::Fx(Effect::Wide)),
("h!", Tok::Fx(Effect::FlipX)),
("v!", Tok::Fx(Effect::FlipY)),
("z!", Tok::Zero),
("c!", Tok::Fx(Effect::Cursed)),
("l!", Tok::Fx(Effect::RotateL)),
("r!", Tok::Fx(Effect::RotateR)),
("p!", Tok::Fx(Effect::Party)),
("s!", Tok::Fx(Effect::Shake)),
("x!", Tok::Fx(Effect::FlipX)),
("y!", Tok::Fx(Effect::FlipY)),
("ffzX", Tok::Fx(Effect::FlipX)),
("ffzY", Tok::Fx(Effect::FlipY)),
("ffzW", Tok::Fx(Effect::Wide)),
("ffzWide", Tok::Fx(Effect::Wide)),
("ffzTall", Tok::Fx(Effect::Tall)),
("ffzCursed", Tok::Fx(Effect::Cursed)),
("ffzHyper", Tok::Fx(Effect::Hyper)),
("ffzRainbow", Tok::Fx(Effect::Rainbow)),
("ffzBounce", Tok::Fx(Effect::Bounce)),
("ffzJam", Tok::Fx(Effect::Jam)),
("ffzSlide", Tok::Fx(Effect::Slide)),
("ffzArrive", Tok::Fx(Effect::Arrive)),
("ffzLeave", Tok::Fx(Effect::Leave)),
("ffzSpin", Tok::Fx(Effect::Spin)),
];
const MAX_SCALE: i32 = 4;
const MAX_REPEAT: usize = 4;
impl Effect {
pub fn code(self) -> char {
CODES.iter().find(|(e, _)| *e == self).map_or('?', |c| c.1)
}
fn from_code(c: char) -> Option<Effect> {
CODES.iter().find(|(_, k)| *k == c).map(|c| c.0)
}
fn composes(self) -> bool {
self <= Effect::Tall
}
pub fn is_motion(self) -> bool {
self >= Effect::Shake
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Geometry {
pub sx: i32,
pub sy: i32,
pub rot: u32,
}
pub fn compose(fx: &[Effect]) -> Geometry {
let (mut sx, mut sy, mut rot) = (1i32, 1i32, 0i32);
for f in fx {
match f {
Effect::Wide => sx = sx.saturating_mul(2),
Effect::Tall => sy = sy.saturating_mul(2),
Effect::FlipX => sx = -sx,
Effect::FlipY => sy = -sy,
Effect::RotateL => rot -= 90,
Effect::RotateR => rot += 90,
_ => {}
}
}
Geometry {
sx: sx.clamp(-MAX_SCALE, MAX_SCALE),
sy: sy.clamp(-MAX_SCALE, MAX_SCALE),
rot: rot.rem_euclid(360) as u32,
}
}
fn hex_hue(hex: &str) -> Option<u16> {
let v: Vec<f64> = match hex.len() {
3 => hex
.chars()
.map(|c| c.to_digit(16).map(|d| (d * 17) as f64 / 255.0))
.collect::<Option<_>>()?,
6 => (0..3)
.map(|i| {
u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16)
.ok()
.map(|b| b as f64 / 255.0)
})
.collect::<Option<_>>()?,
_ => return None,
};
let (r, g, b) = (v[0], v[1], v[2]);
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
let d = max - min;
let mut h = 0.0;
if d > 0.0 {
h = if max == r {
((g - b) / d) % 6.0
} else if max == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
} * 60.0;
if h < 0.0 {
h += 360.0;
}
}
Some(h.round() as u16 % 360)
}
fn color_prefix(s: &str) -> Option<(u16, usize)> {
let rest = s.strip_prefix("c!")?;
let (hash, rest) = match rest.strip_prefix('#') {
Some(r) => (1, r),
None => (0, rest),
};
for n in [6, 3] {
if let Some(h) = rest
.get(..n)
.filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))
{
return Some((hex_hue(h)?, 2 + hash + n));
}
}
None
}
#[derive(Clone, Debug, Default, PartialEq)]
struct Mods {
fx: Vec<Effect>,
zero: bool,
hue: Option<u16>,
}
impl Mods {
fn is_empty(&self) -> bool {
self.fx.is_empty() && !self.zero && self.hue.is_none()
}
fn push_tok(&mut self, t: Tok) {
match t {
Tok::Fx(f) => self.fx.push(f),
Tok::Zero => self.zero = true,
}
}
fn merge(&mut self, o: Mods) {
self.fx.extend(o.fx);
self.zero |= o.zero;
self.hue = o.hue.or(self.hue);
}
}
fn exact_tok(word: &str) -> Option<Tok> {
TOKENS.iter().find(|(k, _)| *k == word).map(|t| t.1)
}
fn tok_prefix(s: &str, bttv_only: bool) -> Option<(&'static str, Tok)> {
TOKENS
.iter()
.filter(|(k, _)| s.starts_with(k) && (!bttv_only || k.ends_with('!')))
.max_by_key(|(k, _)| k.len())
.map(|t| (t.0, t.1))
}
fn peel_chain(word: &str) -> Option<Mods> {
let mut m = Mods::default();
let mut rem = word;
while !rem.is_empty() {
if let Some((hue, n)) = color_prefix(rem) {
m.hue = Some(hue);
rem = &rem[n..];
} else if let Some((k, t)) = tok_prefix(rem, false) {
m.push_tok(t);
rem = &rem[k.len()..];
} else {
return None;
}
}
(!m.is_empty()).then_some(m)
}
struct Modifier {
mods: Mods,
forward: bool,
exact: bool,
}
fn classify(word: &str) -> Option<Modifier> {
if let Some(t) = exact_tok(word) {
let mut mods = Mods::default();
mods.push_tok(t);
return Some(Modifier {
mods,
forward: word.ends_with('!'),
exact: true,
});
}
if let Some((hue, n)) = color_prefix(word) {
if n == word.len() {
let mods = Mods {
hue: Some(hue),
..Mods::default()
};
return Some(Modifier {
mods,
forward: true,
exact: true,
});
}
}
peel_chain(word).map(|mods| Modifier {
mods,
forward: word.ends_with('!'),
exact: false,
})
}
#[derive(Clone, Debug, PartialEq)]
pub struct Layer {
pub url: String,
pub fx: Vec<Effect>,
pub hue: Option<u16>,
}
impl Layer {
fn new(url: impl Into<String>) -> Layer {
Layer {
url: url.into(),
fx: Vec::new(),
hue: None,
}
}
fn add_fx(&mut self, fx: Effect) {
if fx.composes() {
if self.fx.iter().filter(|e| **e == fx).count() >= MAX_REPEAT {
return;
}
} else if self.fx.contains(&fx) {
return;
}
let i = self.fx.partition_point(|e| *e <= fx);
self.fx.insert(i, fx);
}
fn apply(&mut self, m: &Mods) {
for &f in &m.fx {
self.add_fx(f);
}
if m.hue.is_some() {
self.hue = m.hue;
}
}
fn code_line(&self) -> Option<String> {
if self.fx.is_empty() && self.hue.is_none() {
return None;
}
let mut s = String::from("#");
s.extend(self.fx.iter().map(|f| f.code()));
if let Some(h) = self.hue {
s.push('@');
s.push_str(&h.to_string());
}
Some(s)
}
fn set_codes(&mut self, line: &str) {
let (codes, hue) = match line.split_once('@') {
Some((c, h)) => (c, h.parse::<u16>().ok().map(|h| h % 360)),
None => (line, None),
};
for f in codes.chars().filter_map(Effect::from_code) {
self.add_fx(f);
}
self.hue = hue;
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Stack {
pub base: String, pub layers: Vec<Layer>,
}
impl Stack {
fn new(base: &str, layer: Layer) -> Stack {
Stack {
base: base.to_string(),
layers: vec![layer],
}
}
pub fn wide(&self) -> bool {
self.layers
.first()
.is_some_and(|l| compose(&l.fx).sx.abs() >= 2)
}
pub fn key(&self) -> String {
let mut k = String::new();
for (i, l) in self.layers.iter().enumerate() {
if i > 0 {
k.push('\n');
}
k.push_str(&l.url);
if let Some(c) = l.code_line() {
k.push('\n');
k.push_str(&c);
}
}
k
}
}
pub fn split_key(key: &str) -> Vec<Layer> {
let mut layers: Vec<Layer> = Vec::new();
for line in key.split('\n') {
match line.strip_prefix('#') {
Some(codes) => {
if let Some(l) = layers.last_mut() {
l.set_codes(codes);
}
}
None => layers.push(Layer::new(line)),
}
}
layers
}
pub fn ref_set(refs: &[Emote]) -> EmoteSet {
EmoteSet::from_list(refs.iter().cloned())
}
pub fn msg_ref(name: &str, url: &str, provider: &str, zero_width: bool) -> Option<Emote> {
let name = crate::sanitize::clean(name);
let ok_url = url.len() <= 512
&& url.starts_with("https://")
&& !url.chars().any(|c| c.is_whitespace() || c.is_control());
if name.is_empty() || name.chars().any(char::is_whitespace) || !ok_url {
return None;
}
Some(Emote {
name,
url: url.to_string(),
provider: crate::sanitize::clean(provider),
id: String::new(),
animated: false,
zero_width,
global: false,
})
}
fn push_ref(out: &mut Vec<Emote>, e: Option<Emote>) {
if let Some(e) = e {
if !out.iter().any(|o| o.name == e.name) {
out.push(e);
}
}
}
pub fn twitch_native(tag: &str, text: &str) -> Vec<Emote> {
let chars: Vec<char> = text.chars().collect();
let mut out = Vec::new();
for part in tag.split('/') {
let Some((id, pos)) = part.split_once(':') else {
continue;
};
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
continue;
}
let Some((s, e)) = pos
.split(',')
.next()
.and_then(|p| p.split_once('-'))
.and_then(|(s, e)| Some((s.parse::<usize>().ok()?, e.parse::<usize>().ok()?)))
else {
continue;
};
if s > e || e >= chars.len() {
continue;
}
let name: String = chars[s..=e].iter().collect();
let url = format!("https://static-cdn.jtvnw.net/emoticons/v2/{id}/default/dark/2.0");
push_ref(&mut out, msg_ref(&name, &url, "twitch", false));
}
out
}
pub fn kick_native(text: &str) -> (String, Vec<Emote>) {
const OPEN: &str = "[emote:";
let mut out = String::with_capacity(text.len());
let mut refs = Vec::new();
let mut rest = text;
while let Some(i) = rest.find(OPEN) {
out.push_str(&rest[..i]);
let after = &rest[i + OPEN.len()..];
let token = after.split_once(']').and_then(|(body, tail)| {
let (id, name) = body.split_once(':')?;
let url = format!("https://files.kick.com/emotes/{id}/fullsize");
let ok = !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit());
ok.then(|| msg_ref(name, &url, "kick", false).map(|e| (e, tail)))?
});
match token {
Some((e, tail)) => {
if !out.is_empty() && !out.ends_with(char::is_whitespace) {
out.push(' ');
}
out.push_str(&e.name);
if tail.chars().next().is_some_and(|c| !c.is_whitespace()) {
out.push(' ');
}
push_ref(&mut refs, Some(e));
rest = tail;
}
None => {
out.push_str(OPEN);
rest = after;
}
}
}
out.push_str(rest);
(out, refs)
}
#[derive(Clone, Debug, PartialEq)]
pub enum Segment {
Text(String),
Stack(Stack),
}
struct Lookup<'a> {
set: &'a EmoteSet,
refs: &'a EmoteSet,
}
impl<'a> Lookup<'a> {
fn get(&self, name: &str) -> Option<&'a Emote> {
self.refs.get(name).or_else(|| self.set.get(name))
}
}
struct Resolved<'a> {
emote: &'a Emote,
overlay: bool,
mods: Mods,
}
fn resolve<'a>(word: &str, look: &Lookup<'a>) -> Option<Resolved<'a>> {
if let Some(e) = look.get(word) {
return Some(Resolved {
emote: e,
overlay: e.zero_width,
mods: Mods::default(),
});
}
if let Some(base) = word.strip_suffix('0').filter(|b| !b.is_empty()) {
if let Some(e) = look.get(base) {
return Some(Resolved {
emote: e,
overlay: true,
mods: Mods::default(),
});
}
}
if !word.contains('!') && !word.contains("ffz") {
return None;
}
for (i, _) in word.char_indices().rev() {
if i < 2 {
break;
}
let (base, rest) = word.split_at(i);
if let Some(mods) = peel_chain(rest) {
if let Some(e) = look.get(base) {
return Some(Resolved {
emote: e,
overlay: e.zero_width || mods.zero,
mods,
});
}
}
}
let mut mods = Mods::default();
let mut rem = word;
while let Some((k, t)) = tok_prefix(rem, true).filter(|(k, _)| rem.len() > k.len()) {
mods.push_tok(t);
rem = &rem[k.len()..];
}
if rem.len() < word.len() {
if let Some(e) = look.get(rem) {
return Some(Resolved {
emote: e,
overlay: e.zero_width || mods.zero,
mods,
});
}
}
None
}
fn emote_ahead(words: &[&str], from: usize, look: &Lookup) -> bool {
for w in &words[from.min(words.len())..] {
if is_modifier(w, look) {
continue;
}
return resolve(w, look).is_some();
}
false
}
fn is_modifier(word: &str, look: &Lookup) -> bool {
classify(word).is_some_and(|c| c.exact || look.get(word).is_none())
}
pub fn emotes_in<'a>(content: &str, set: &'a EmoteSet, refs: &'a EmoteSet) -> Vec<&'a Emote> {
let look = Lookup { set, refs };
let mut out: Vec<&Emote> = Vec::new();
for w in content.split_whitespace() {
if let Some(e) = look.get(w) {
if !out.iter().any(|o| o.url == e.url) {
out.push(e);
}
}
}
out
}
pub fn segments(content: &str, set: &EmoteSet) -> Vec<Segment> {
segments_with(content, set, &EmoteSet::new())
}
pub fn segments_with(content: &str, set: &EmoteSet, refs: &EmoteSet) -> Vec<Segment> {
let look = Lookup { set, refs };
let words: Vec<&str> = content.split_whitespace().collect();
let mut out: Vec<Segment> = Vec::new();
let mut text = String::new();
let mut pending = Mods::default();
let mut pending_words: Vec<&str> = Vec::new();
fn push_text(text: &mut String, w: &str) {
if !text.is_empty() {
text.push(' ');
}
text.push_str(w);
}
for (i, &word) in words.iter().enumerate() {
if let Some(c) = classify(word).filter(|c| c.exact || look.get(word).is_none()) {
let ahead = emote_ahead(&words, i + 1, &look);
let back = text.is_empty() && matches!(out.last(), Some(Segment::Stack(_)));
let back_ok = back && (!c.mods.fx.is_empty() || c.mods.hue.is_some());
let fwd = if c.forward { ahead } else { !back_ok && ahead };
if fwd {
pending.merge(c.mods);
pending_words.push(word);
continue;
}
if back_ok {
if let Some(Segment::Stack(s)) = out.last_mut() {
if let Some(l) = s.layers.last_mut() {
l.apply(&c.mods);
continue;
}
}
}
for w in pending_words.drain(..) {
push_text(&mut text, w);
}
pending = Mods::default();
push_text(&mut text, word);
continue;
}
let Some(r) = resolve(word, &look) else {
for w in pending_words.drain(..) {
push_text(&mut text, w);
}
pending = Mods::default();
push_text(&mut text, word);
continue;
};
if crate::block::is_blocked(r.emote) {
pending = Mods::default();
pending_words.clear();
continue;
}
let mut mods = std::mem::take(&mut pending);
pending_words.clear();
mods.merge(r.mods);
let overlay = r.overlay || mods.zero;
let mut layer = Layer::new(r.emote.url.clone());
layer.apply(&mods);
if overlay && text.is_empty() {
if let Some(Segment::Stack(s)) = out.last_mut() {
s.layers.push(layer);
continue;
}
}
if !text.is_empty() {
out.push(Segment::Text(std::mem::take(&mut text)));
}
out.push(Segment::Stack(Stack::new(&r.emote.name, layer)));
}
for w in pending_words.drain(..) {
push_text(&mut text, w);
}
if !text.is_empty() {
out.push(Segment::Text(text));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn e(name: &str) -> Emote {
Emote {
name: name.into(),
url: format!("https://cdn/{name}.webp"),
provider: "7tv".into(),
id: name.into(),
animated: false,
zero_width: false,
global: false,
}
}
fn zw(name: &str) -> Emote {
Emote {
zero_width: true,
..e(name)
}
}
fn native(name: &str, provider: &str) -> Emote {
Emote {
url: format!("https://native/{name}"),
provider: provider.into(),
..e(name)
}
}
fn cast() -> EmoteSet {
EmoteSet::from_list([
e("KEKW"),
e("GAMBA"),
e("Kappa"),
e("Keepo"),
e("lerolero"),
zw("notL"),
])
}
fn show_with(msg: &str, set: &EmoteSet, refs: &EmoteSet) -> String {
segments_with(msg, set, refs)
.iter()
.map(|s| match s {
Segment::Text(t) => format!("'{t}'"),
Segment::Stack(st) => st
.layers
.iter()
.map(|l| {
let name = l.url.rsplit('/').next().unwrap();
let name = name.strip_suffix(".webp").unwrap_or(name);
let codes = l.code_line().unwrap_or_default();
match codes.strip_prefix('#') {
Some(c) => format!("{name}[{c}]"),
None => name.to_string(),
}
})
.collect::<Vec<_>>()
.join("+"),
})
.collect::<Vec<_>>()
.join(" ")
}
fn show(msg: &str) -> String {
show_with(msg, &cast(), &EmoteSet::new())
}
#[track_caller]
fn table(cases: &[(&str, &str)]) {
for (input, want) in cases {
assert_eq!(show(input), *want, "input: {input}");
}
}
#[test]
fn channel_emote_overrides_global_regardless_of_arrival_order() {
let mut set = EmoteSet::new();
let mut globals = EmoteSet::from_list([e("Kappa")]);
globals.mark_global();
set.merge(globals.clone());
set.merge(EmoteSet::from_list([Emote {
provider: "bttv".into(),
..e("Kappa")
}]));
assert_eq!(set.get("Kappa").unwrap().provider, "bttv");
let mut set = EmoteSet::from_list([Emote {
provider: "bttv".into(),
..e("Kappa")
}]);
set.merge(globals);
assert_eq!(set.get("Kappa").unwrap().provider, "bttv");
assert_eq!(set.len(), 1);
}
#[test]
fn last_entry_wins_on_collision() {
let p = |n: &str| Emote {
provider: n.into(),
..e("Kappa")
};
let set = EmoteSet::from_list([p("bttv"), p("7tv"), p("ffz")]);
assert_eq!(set.get("Kappa").unwrap().provider, "ffz");
assert_eq!(set.len(), 1);
let set = EmoteSet::from_list([p("bttv"), p("7tv")]);
assert_eq!(set.get("Kappa").unwrap().provider, "7tv");
}
#[test]
fn globals_rank_7tv_bttv_ffz_whatever_the_row_order() {
let p = |n: &str| Emote {
provider: n.into(),
..e("Kappa")
};
for rows in [
vec![p("7tv"), p("bttv"), p("ffz"), p("twitch")],
vec![p("twitch"), p("ffz"), p("bttv"), p("7tv")],
vec![p("ffz"), p("7tv"), p("twitch"), p("bttv")],
] {
let set = EmoteSet::from_global_list(rows);
let k = set.get("Kappa").unwrap();
assert_eq!(k.provider, "7tv");
assert!(k.global);
}
let set = EmoteSet::from_global_list([p("twitch"), p("ffz")]);
assert_eq!(set.get("Kappa").unwrap().provider, "ffz");
}
#[test]
fn text_and_emotes_coalesce() {
table(&[
(
"lol KEKW that GAMBA moment",
"'lol' KEKW 'that' GAMBA 'moment'",
),
("a b\tc", "'a b c'"),
("", ""),
]);
}
#[test]
fn bttv_binds_forward() {
table(&[
("w!KEKW", "KEKW[w]"),
("w! KEKW", "KEKW[w]"),
("w!h!KEKW", "KEKW[xw]"), ("x! KEKW", "KEKW[x]"),
("y! KEKW", "KEKW[y]"),
("v! KEKW", "KEKW[y]"),
("l! KEKW", "KEKW[l]"),
("r! KEKW", "KEKW[r]"),
("c! KEKW", "KEKW[c]"),
("p! KEKW", "KEKW[p]"),
("s! KEKW", "KEKW[s]"),
("Kappa c! Keepo", "Kappa Keepo[c]"),
("c! ffzX Kappa", "Kappa[xc]"),
("w! h! KEKW", "KEKW[xw]"),
]);
}
#[test]
fn ffz_binds_backward() {
table(&[
("KEKW ffzX", "KEKW[x]"),
("KEKW ffzY", "KEKW[y]"),
("KEKW ffzW", "KEKW[w]"),
("KEKW ffzWide", "KEKW[w]"),
("KEKW ffzTall", "KEKW[t]"),
("KEKW ffzCursed", "KEKW[c]"),
("KEKW ffzHyper", "KEKW[H]"),
("KEKW ffzRainbow", "KEKW[R]"),
("KEKW ffzBounce", "KEKW[b]"),
("KEKW ffzJam", "KEKW[j]"),
("KEKW ffzSlide", "KEKW[d]"),
("KEKW ffzArrive", "KEKW[a]"),
("KEKW ffzLeave", "KEKW[e]"),
("KEKW ffzSpin", "KEKW[o]"),
("KEKW ffzX ffzRainbow", "KEKW[xR]"),
("Kappa ffzX Keepo", "Kappa[x] Keepo"),
("GAMBA notL ffzX", "GAMBA+notL[x]"),
]);
}
#[test]
fn fallback_direction_when_canonical_side_has_no_emote() {
table(&[
("Kappa w!", "Kappa[w]"), ("ffzX Kappa", "Kappa[x]"), ("Kappa c!#00f", "Kappa[@240]"),
]);
}
#[test]
fn orphan_modifier_stays_literal() {
table(&[
("w! nothing here", "'w! nothing here'"),
("lol ffzX", "'lol ffzX'"),
("KEKW lol ffzX", "KEKW 'lol ffzX'"), ("w! ffzX", "'w! ffzX'"),
("c!#00f", "'c!#00f'"),
("z!", "'z!'"),
("hi w!h!ffzX", "'hi w!h!ffzX'"),
]);
}
#[test]
fn glued_suffixes() {
table(&[
("Kappaw!", "Kappa[w]"),
("KappaffzX", "Kappa[x]"),
("KappaffzRainbow", "Kappa[R]"),
("KappaffzLeave", "Kappa[e]"),
("Kappac!#0f0", "Kappa[@120]"),
("Kappac!888", "Kappa[@0]"),
("Kappaw!h!ffzX", "Kappa[xxw]"),
("Kappaw!c!#00f", "Kappa[w@240]"),
("GAMBA notLw!", "GAMBA+notL[w]"),
]);
}
#[test]
fn glued_prefixes_still_work() {
table(&[("w!h!KEKW", "KEKW[xw]"), ("z!KEKW", "KEKW")]);
}
#[test]
fn chained_words_follow_the_trailing_bang() {
table(&[
("Kappa w!h!ffzX", "Kappa[xxw]"),
("Kappa Keepo w!h!ffzX", "Kappa Keepo[xxw]"),
("Kappa w!h! Keepo", "Kappa Keepo[xw]"),
("w!c!#00fh! Kappa", "Kappa[xw@240]"),
]);
}
#[test]
fn hex_colors_tint_and_latest_wins() {
table(&[
("c!#f00 Kappa", "Kappa[@0]"),
("c!#0f0 Kappa", "Kappa[@120]"),
("c!#0000ff Kappa", "Kappa[@240]"),
("c!00f Kappa", "Kappa[@240]"), ("c!#f00 c!#00f Kappa", "Kappa[@240]"),
]);
assert_eq!(hex_hue("ff8700"), Some(32));
assert_eq!(hex_hue("zzz"), None);
assert_eq!(hex_hue("12"), None);
}
#[test]
fn composition_per_hscomposeall() {
table(&[
("w! w! KEKW", "KEKW[ww]"),
("KEKW ffzRainbow ffzRainbow", "KEKW[R]"),
("KEKW ffzHyper ffzHyper s!", "KEKW[Hs]"),
("w!w!w!w!w!w!KEKW", "KEKW[wwww]"), ]);
let g = |fx: &[Effect]| compose(fx);
assert_eq!((g(&[]).sx, g(&[]).sy, g(&[]).rot), (1, 1, 0));
let wide3 = g(&[Effect::Wide; 3]);
assert_eq!(wide3.sx, 4); assert_eq!(g(&[Effect::FlipX, Effect::FlipX]).sx, 1); assert_eq!(g(&[Effect::FlipX, Effect::Wide]).sx, -2);
assert_eq!(g(&[Effect::Tall]).sy, 2);
assert_eq!(g(&[Effect::RotateL, Effect::RotateL]).rot, 180);
assert_eq!(g(&[Effect::RotateL, Effect::RotateR]).rot, 0);
assert_eq!(g(&[Effect::RotateL]).rot, 270);
assert_eq!(g(&[Effect::RotateR; 4]).rot, 0);
}
#[test]
fn wide_is_the_base_layers_footprint() {
let set = cast();
let wide = |m: &str| match &segments(m, &set)[0] {
Segment::Stack(s) => s.wide(),
_ => panic!("no stack"),
};
assert!(wide("w! KEKW"));
assert!(wide("KEKW ffzWide"));
assert!(!wide("KEKW ffzTall"));
assert!(wide("w! w! w! KEKW")); assert!(wide("w! h! KEKW")); assert!(!wide("GAMBA notL ffzW")); assert!(!wide("KEKW"));
}
#[test]
fn zero_width_overlays_stack() {
table(&[
("GAMBA notL", "GAMBA+notL"),
("GAMBA notL notL", "GAMBA+notL+notL"),
("notL", "notL"), ("GAMBA lol notL", "GAMBA 'lol' notL"),
("GAMBA w! notL", "GAMBA+notL[w]"),
("GAMBA notL w!", "GAMBA+notL[w]"),
]);
}
#[test]
fn z_forces_overlay() {
table(&[
("GAMBA z!KEKW", "GAMBA+KEKW"),
("GAMBA z! KEKW", "GAMBA+KEKW"),
("GAMBA z!w!KEKW", "GAMBA+KEKW[w]"),
("GAMBA z!", "GAMBA 'z!'"), ]);
}
#[test]
fn name0_overlay_convention() {
table(&[
("GAMBA lerolero0", "GAMBA+lerolero"),
("lerolero0", "lerolero"), ]);
let set = EmoteSet::from_list([e("GAMBA"), e("lerolero"), e("lerolero0")]);
assert_eq!(
show_with("GAMBA lerolero0", &set, &EmoteSet::new()),
"GAMBA lerolero0"
);
assert_eq!(show("nope0"), "'nope0'");
assert_eq!(show("0"), "'0'");
}
#[test]
fn exact_emote_name_beats_chain_parse() {
let set = EmoteSet::from_list([e("h!i"), e("w!h!")]);
assert_eq!(show_with("h!i", &set, &EmoteSet::new()), "h!i");
assert_eq!(show_with("w!h!", &set, &EmoteSet::new()), "w!h!");
}
#[test]
fn refs_outrank_the_set_native_included() {
let set = EmoteSet::from_list([e("Kappa")]);
let heat = Emote {
url: "https://hs/Kappa".into(),
provider: "heatsync".into(),
..e("Kappa")
};
let refs = EmoteSet::from_list([heat]);
assert_eq!(show_with("Kappa", &set, &refs), "Kappa");
match &segments_with("Kappa", &set, &refs)[0] {
Segment::Stack(s) => assert_eq!(s.layers[0].url, "https://hs/Kappa"),
_ => panic!(),
}
let refs = EmoteSet::from_list([native("Kappa", "twitch")]);
match &segments_with("Kappa", &set, &refs)[0] {
Segment::Stack(s) => assert_eq!(s.layers[0].url, "https://native/Kappa"),
_ => panic!(),
}
let refs = EmoteSet::from_list([native("SubEmote", "twitch")]);
match &segments_with("w! SubEmote ffzX", &set, &refs)[0] {
Segment::Stack(s) => {
assert_eq!(s.layers[0].url, "https://native/SubEmote");
assert_eq!(s.layers[0].fx, vec![Effect::FlipX, Effect::Wide]);
}
_ => panic!(),
}
}
#[test]
fn keys_are_unique_per_effect_and_roundtrip() {
let set = cast();
let mut keys = std::collections::HashSet::new();
for (name, _) in TOKENS.iter().filter(|(k, _)| *k != "z!") {
let msg = if name.ends_with('!') {
format!("{name} KEKW")
} else {
format!("KEKW {name}")
};
match &segments(&msg, &set)[0] {
Segment::Stack(s) => keys.insert(s.key()),
_ => panic!("{msg}"),
};
}
assert_eq!(keys.len(), 17);
assert_eq!(CODES.len(), 17);
let codes: std::collections::HashSet<_> = CODES.iter().map(|c| c.1).collect();
assert_eq!(codes.len(), 17, "stack-key codes are unique");
}
#[test]
fn key_roundtrips_through_split_key() {
let set = cast();
let s = match &segments("w!GAMBA notL ffzCursed c!#0f0", &set)[0] {
Segment::Stack(s) => s.clone(),
_ => panic!(),
};
assert_eq!(
s.key(),
"https://cdn/GAMBA.webp\n#w\nhttps://cdn/notL.webp\n#c@120"
);
assert_eq!(split_key(&s.key()), s.layers);
let plain = match &segments("GAMBA notL", &set)[0] {
Segment::Stack(s) => s.key(),
_ => panic!(),
};
assert_eq!(plain, "https://cdn/GAMBA.webp\nhttps://cdn/notL.webp");
assert_eq!(
split_key("https://x/a\n#xw@7")[0].fx,
vec![Effect::FlipX, Effect::Wide]
);
assert_eq!(split_key("https://x/a\n#xw@7")[0].hue, Some(7));
assert!(split_key("#xw").is_empty());
}
#[test]
fn twitch_native_positions_name_the_covered_text() {
let u =
|id: &str| format!("https://static-cdn.jtvnw.net/emoticons/v2/{id}/default/dark/2.0");
for (tag, text, want) in [
("25:0-4", "Kappa hi", vec![("Kappa", "25")]),
("25:0-4,12-16", "Kappa hi Kappa", vec![("Kappa", "25")]),
(
"25:0-4/1902:6-10",
"Kappa Keepo",
vec![("Kappa", "25"), ("Keepo", "1902")],
),
(
"emotesv2_ab12:3-5",
"hi LUL",
vec![("LUL", "emotesv2_ab12")],
),
("25:3-7", "\u{1f600}\u{1f600} Kappa", vec![("Kappa", "25")]),
("25:0-4/", "Kappa hi", vec![("Kappa", "25")]),
("", "Kappa", vec![]),
] {
let got: Vec<(String, String)> = twitch_native(tag, text)
.into_iter()
.map(|e| (e.name, e.url))
.collect();
let want: Vec<(String, String)> =
want.into_iter().map(|(n, i)| (n.into(), u(i))).collect();
assert_eq!(got, want, "{tag} / {text}");
}
}
#[test]
fn twitch_native_rejects_bad_input() {
for (tag, text) in [
("25:0-9", "Kappa"), ("25:4-0", "Kappa"), ("25:a-b", "Kappa"), ("25", "Kappa"), ("25?x=1:0-4", "Kappa"), ("25:0-6", "Kappa hi"), ] {
let got = twitch_native(tag, text);
assert!(got.is_empty(), "{tag}: {got:?}");
}
}
#[test]
fn kick_native_tokens_become_names_and_refs() {
let (text, refs) = kick_native("hi [emote:37226:KEKW] there");
assert_eq!(text, "hi KEKW there");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].name, "KEKW");
assert_eq!(refs[0].url, "https://files.kick.com/emotes/37226/fullsize");
assert_eq!(refs[0].provider, "kick");
assert_eq!(kick_native("a[emote:1:X]b").0, "a X b");
assert_eq!(kick_native("[emote:1:X][emote:2:Y]").0, "X Y");
assert_eq!(kick_native("[emote:1:X][emote:2:Y]").1.len(), 2);
assert_eq!(kick_native("[emote:1:X] [emote:1:X]").1.len(), 1);
for bad in [
"[emote:abc:X]",
"[emote::X]",
"[emote:1:]",
"[emote:1:X Y]",
"[emote:1:X",
"[emote:../x:X]",
"[emote:12]",
] {
let (t, r) = kick_native(bad);
assert_eq!(t, bad, "{bad}");
assert!(r.is_empty(), "{bad}");
}
assert_eq!(kick_native("plain").0, "plain");
}
#[test]
fn msg_ref_guards_the_trust_boundary() {
assert!(msg_ref("KEKW", "https://cdn/k.webp", "7tv", false).is_some());
for (n, u) in [
("", "https://cdn/k"),
("a b", "https://cdn/k"),
("KEKW", "http://cdn/k"),
("KEKW", "javascript:x"),
("KEKW", "https://cdn/k k"),
("KEKW", "https://cdn/k\u{1b}[2J"),
] {
assert!(msg_ref(n, u, "x", false).is_none(), "{n} {u}");
}
assert_eq!(
msg_ref("KE\u{1b}KW", "https://cdn/k", "x", false)
.unwrap()
.name,
"KEKW"
);
}
#[test]
fn native_refs_drive_a_full_parse() {
let set = cast();
let (text, refs) = kick_native("[emote:9:SubOnly] w! KEKW");
let r = ref_set(&refs);
let segs = segments_with(&text, &set, &r);
let bases: Vec<&str> = segs
.iter()
.filter_map(|s| match s {
Segment::Stack(st) => Some(st.base.as_str()),
_ => None,
})
.collect();
assert_eq!(bases, vec!["SubOnly", "KEKW"]);
match &segs[0] {
Segment::Stack(st) => {
assert_eq!(st.layers[0].url, "https://files.kick.com/emotes/9/fullsize")
}
_ => panic!(),
}
}
#[test]
fn parses_real_api_json() {
let json = r#"{"channel":"xqc","emotes":[
{"name":"GAMBA","url":"https://cdn.7tv.app/emote/x/1x.webp","provider":"7tv","id":"x","animated":true}
],"count":1}"#;
let resp: EmoteSetResponse = serde_json::from_str(json).unwrap();
let set = EmoteSet::from_list(resp.emotes);
assert!(set.get("GAMBA").unwrap().animated);
}
#[test]
fn owner_emotes_are_lenient_per_row_and_https_only() {
let v = serde_json::json!({"emotes": [
{"name": "A", "url": "https://cdn.heatsync.org/a.webp", "provider": "upload", "hash": "h1"},
{"name": "B", "url": "/emotes/b.webp"},
{"name": "C", "url": "http://x/c.webp"},
{"name": "D", "url": "//evil/d.webp"},
{"url": "https://x/e.webp"},
{"name": "F", "url": ""},
"junk"]});
let got = owner_list(&v, "https://heatsync.org");
let names: Vec<_> = got.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, ["A", "B"]);
assert_eq!(got[0].id, "h1");
assert_eq!(got[1].url, "https://heatsync.org/emotes/b.webp");
assert_eq!(got[1].provider, "upload");
assert!(owner_list(&serde_json::json!({}), "x").is_empty());
assert!(owner_list(&serde_json::json!({"emotes": 5}), "x").is_empty());
}
#[test]
fn owner_emotes_listed_last_win_a_name() {
let chan = vec![e("W")];
let mut mine = e("W");
mine.provider = "upload".into();
mine.url = "https://heatsync.org/w.webp".into();
let set = EmoteSet::from_list(chan.into_iter().chain([mine]));
assert_eq!(set.get("W").unwrap().provider, "upload");
}
#[test]
fn a_blocked_emote_vanishes_but_its_same_named_twin_stays() {
let mut mine = e("BlockTwin");
mine.url = "https://block.test/seg/mine.webp".into();
let mut other = e("BlockTwin");
other.url = "https://block.test/seg/other.webp".into();
let set_a = EmoteSet::from_list([mine.clone()]);
let set_b = EmoteSet::from_list([other.clone()]);
let text = |s: &[Segment]| -> String {
s.iter()
.map(|g| match g {
Segment::Text(t) => t.clone(),
Segment::Stack(_) => "<emote>".into(),
})
.collect::<Vec<_>>()
.join("|")
};
assert_eq!(text(&segments("hi BlockTwin yo", &set_a)), "hi|<emote>|yo");
assert!(crate::block::block(&mine));
assert_eq!(
text(&segments("hi BlockTwin yo", &set_a)),
"hi yo",
"hidden"
);
assert_eq!(
text(&segments("hi BlockTwin yo", &set_b)),
"hi|<emote>|yo",
"the other emote with that name is untouched"
);
assert_eq!(text(&segments("w! BlockTwin", &set_a)), "");
assert_eq!(
emotes_in("a BlockTwin BlockTwin", &set_a, EmoteSet::none()).len(),
1
);
crate::block::unblock_key(&crate::block::key(&mine).unwrap());
assert_eq!(text(&segments("BlockTwin", &set_a)), "<emote>");
}
}