1use std::collections::HashMap;
8
9use serde::Deserialize;
10
11#[derive(Clone, Debug, PartialEq, Deserialize)]
13pub struct Emote {
14 pub name: String,
15 pub url: String,
16 pub provider: String,
17 #[serde(default)]
18 pub id: String,
19 #[serde(default)]
20 pub animated: bool,
21 #[serde(rename = "zeroWidth", default)]
22 pub zero_width: bool,
23 #[serde(skip)]
26 pub global: bool,
27}
28
29#[derive(Debug, Deserialize)]
31pub struct EmoteSetResponse {
32 #[serde(default)]
33 pub emotes: Vec<Emote>,
34}
35
36#[derive(Clone, Debug, Default)]
38pub struct EmoteSet {
39 map: HashMap<String, Emote>,
40}
41
42impl EmoteSet {
43 pub fn new() -> EmoteSet {
44 EmoteSet::default()
45 }
46
47 pub fn none() -> &'static EmoteSet {
49 static NONE: std::sync::OnceLock<EmoteSet> = std::sync::OnceLock::new();
50 NONE.get_or_init(EmoteSet::new)
51 }
52
53 pub fn from_list(list: impl IntoIterator<Item = Emote>) -> EmoteSet {
56 let mut set = EmoteSet::new();
57 for e in list {
58 set.map.entry(e.name.clone()).or_insert(e);
59 }
60 set
61 }
62
63 pub fn merge(&mut self, other: EmoteSet) {
69 for (k, v) in other.map {
70 match self.map.get(&k) {
71 Some(cur) if cur.global && !v.global => {
72 self.map.insert(k, v);
73 }
74 Some(_) => {}
75 None => {
76 self.map.insert(k, v);
77 }
78 }
79 }
80 }
81
82 pub fn mark_global(&mut self) {
84 for e in self.map.values_mut() {
85 e.global = true;
86 }
87 }
88
89 pub fn get(&self, name: &str) -> Option<&Emote> {
90 self.map.get(name)
91 }
92
93 pub fn names(&self) -> impl Iterator<Item = &str> {
95 self.map.keys().map(String::as_str)
96 }
97
98 pub fn len(&self) -> usize {
99 self.map.len()
100 }
101
102 pub fn is_empty(&self) -> bool {
103 self.map.is_empty()
104 }
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
111pub enum Effect {
112 RotateL, RotateR, FlipX, FlipY, Wide, Tall, Cursed, Hyper, Party, Rainbow, Shake, Bounce, Jam, Slide, Arrive, Leave, Spin, }
130
131const CODES: [(Effect, char); 17] = [
133 (Effect::RotateL, 'l'),
134 (Effect::RotateR, 'r'),
135 (Effect::FlipX, 'x'),
136 (Effect::FlipY, 'y'),
137 (Effect::Wide, 'w'),
138 (Effect::Tall, 't'),
139 (Effect::Cursed, 'c'),
140 (Effect::Hyper, 'H'),
141 (Effect::Party, 'p'),
142 (Effect::Rainbow, 'R'),
143 (Effect::Shake, 's'),
144 (Effect::Bounce, 'b'),
145 (Effect::Jam, 'j'),
146 (Effect::Slide, 'd'),
147 (Effect::Arrive, 'a'),
148 (Effect::Leave, 'e'),
149 (Effect::Spin, 'o'),
150];
151
152#[derive(Clone, Copy)]
154enum Tok {
155 Fx(Effect),
156 Zero,
157}
158
159const TOKENS: [(&str, Tok); 25] = [
162 ("w!", Tok::Fx(Effect::Wide)),
163 ("h!", Tok::Fx(Effect::FlipX)),
164 ("v!", Tok::Fx(Effect::FlipY)),
165 ("z!", Tok::Zero),
166 ("c!", Tok::Fx(Effect::Cursed)),
167 ("l!", Tok::Fx(Effect::RotateL)),
168 ("r!", Tok::Fx(Effect::RotateR)),
169 ("p!", Tok::Fx(Effect::Party)),
170 ("s!", Tok::Fx(Effect::Shake)),
171 ("x!", Tok::Fx(Effect::FlipX)),
172 ("y!", Tok::Fx(Effect::FlipY)),
173 ("ffzX", Tok::Fx(Effect::FlipX)),
174 ("ffzY", Tok::Fx(Effect::FlipY)),
175 ("ffzW", Tok::Fx(Effect::Wide)),
176 ("ffzWide", Tok::Fx(Effect::Wide)),
177 ("ffzTall", Tok::Fx(Effect::Tall)),
178 ("ffzCursed", Tok::Fx(Effect::Cursed)),
179 ("ffzHyper", Tok::Fx(Effect::Hyper)),
180 ("ffzRainbow", Tok::Fx(Effect::Rainbow)),
181 ("ffzBounce", Tok::Fx(Effect::Bounce)),
182 ("ffzJam", Tok::Fx(Effect::Jam)),
183 ("ffzSlide", Tok::Fx(Effect::Slide)),
184 ("ffzArrive", Tok::Fx(Effect::Arrive)),
185 ("ffzLeave", Tok::Fx(Effect::Leave)),
186 ("ffzSpin", Tok::Fx(Effect::Spin)),
187];
188
189const MAX_SCALE: i32 = 4;
191const MAX_REPEAT: usize = 4;
194
195impl Effect {
196 pub fn code(self) -> char {
198 CODES.iter().find(|(e, _)| *e == self).map_or('?', |c| c.1)
199 }
200
201 fn from_code(c: char) -> Option<Effect> {
202 CODES.iter().find(|(_, k)| *k == c).map(|c| c.0)
203 }
204
205 fn composes(self) -> bool {
208 self <= Effect::Tall
209 }
210
211 pub fn is_motion(self) -> bool {
213 self >= Effect::Shake
214 }
215}
216
217#[derive(Clone, Copy, Debug, PartialEq, Eq)]
220pub struct Geometry {
221 pub sx: i32,
222 pub sy: i32,
223 pub rot: u32,
225}
226
227pub fn compose(fx: &[Effect]) -> Geometry {
228 let (mut sx, mut sy, mut rot) = (1i32, 1i32, 0i32);
229 for f in fx {
230 match f {
231 Effect::Wide => sx = sx.saturating_mul(2),
232 Effect::Tall => sy = sy.saturating_mul(2),
233 Effect::FlipX => sx = -sx,
234 Effect::FlipY => sy = -sy,
235 Effect::RotateL => rot -= 90,
236 Effect::RotateR => rot += 90,
237 _ => {}
238 }
239 }
240 Geometry {
241 sx: sx.clamp(-MAX_SCALE, MAX_SCALE),
242 sy: sy.clamp(-MAX_SCALE, MAX_SCALE),
243 rot: rot.rem_euclid(360) as u32,
244 }
245}
246
247fn hex_hue(hex: &str) -> Option<u16> {
249 let v: Vec<f64> = match hex.len() {
250 3 => hex
251 .chars()
252 .map(|c| c.to_digit(16).map(|d| (d * 17) as f64 / 255.0))
253 .collect::<Option<_>>()?,
254 6 => (0..3)
255 .map(|i| {
256 u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16)
257 .ok()
258 .map(|b| b as f64 / 255.0)
259 })
260 .collect::<Option<_>>()?,
261 _ => return None,
262 };
263 let (r, g, b) = (v[0], v[1], v[2]);
264 let (max, min) = (r.max(g).max(b), r.min(g).min(b));
265 let d = max - min;
266 let mut h = 0.0;
267 if d > 0.0 {
268 h = if max == r {
269 ((g - b) / d) % 6.0
270 } else if max == g {
271 (b - r) / d + 2.0
272 } else {
273 (r - g) / d + 4.0
274 } * 60.0;
275 if h < 0.0 {
276 h += 360.0;
277 }
278 }
279 Some(h.round() as u16 % 360)
280}
281
282fn color_prefix(s: &str) -> Option<(u16, usize)> {
285 let rest = s.strip_prefix("c!")?;
286 let (hash, rest) = match rest.strip_prefix('#') {
287 Some(r) => (1, r),
288 None => (0, rest),
289 };
290 for n in [6, 3] {
291 if let Some(h) = rest
292 .get(..n)
293 .filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))
294 {
295 return Some((hex_hue(h)?, 2 + hash + n));
296 }
297 }
298 None
299}
300
301#[derive(Clone, Debug, Default, PartialEq)]
303struct Mods {
304 fx: Vec<Effect>,
305 zero: bool,
306 hue: Option<u16>,
307}
308
309impl Mods {
310 fn is_empty(&self) -> bool {
311 self.fx.is_empty() && !self.zero && self.hue.is_none()
312 }
313
314 fn push_tok(&mut self, t: Tok) {
315 match t {
316 Tok::Fx(f) => self.fx.push(f),
317 Tok::Zero => self.zero = true,
318 }
319 }
320
321 fn merge(&mut self, o: Mods) {
323 self.fx.extend(o.fx);
324 self.zero |= o.zero;
325 self.hue = o.hue.or(self.hue);
326 }
327}
328
329fn exact_tok(word: &str) -> Option<Tok> {
330 TOKENS.iter().find(|(k, _)| *k == word).map(|t| t.1)
331}
332
333fn tok_prefix(s: &str, bttv_only: bool) -> Option<(&'static str, Tok)> {
335 TOKENS
336 .iter()
337 .filter(|(k, _)| s.starts_with(k) && (!bttv_only || k.ends_with('!')))
338 .max_by_key(|(k, _)| k.len())
339 .map(|t| (t.0, t.1))
340}
341
342fn peel_chain(word: &str) -> Option<Mods> {
344 let mut m = Mods::default();
345 let mut rem = word;
346 while !rem.is_empty() {
347 if let Some((hue, n)) = color_prefix(rem) {
348 m.hue = Some(hue);
349 rem = &rem[n..];
350 } else if let Some((k, t)) = tok_prefix(rem, false) {
351 m.push_tok(t);
352 rem = &rem[k.len()..];
353 } else {
354 return None;
355 }
356 }
357 (!m.is_empty()).then_some(m)
358}
359
360struct Modifier {
362 mods: Mods,
363 forward: bool,
365 exact: bool,
368}
369
370fn classify(word: &str) -> Option<Modifier> {
371 if let Some(t) = exact_tok(word) {
372 let mut mods = Mods::default();
373 mods.push_tok(t);
374 return Some(Modifier {
375 mods,
376 forward: word.ends_with('!'),
377 exact: true,
378 });
379 }
380 if let Some((hue, n)) = color_prefix(word) {
381 if n == word.len() {
382 let mods = Mods {
383 hue: Some(hue),
384 ..Mods::default()
385 };
386 return Some(Modifier {
388 mods,
389 forward: true,
390 exact: true,
391 });
392 }
393 }
394 peel_chain(word).map(|mods| Modifier {
396 mods,
397 forward: word.ends_with('!'),
398 exact: false,
399 })
400}
401
402#[derive(Clone, Debug, PartialEq)]
405pub struct Layer {
406 pub url: String,
407 pub fx: Vec<Effect>,
409 pub hue: Option<u16>,
411}
412
413impl Layer {
414 fn new(url: impl Into<String>) -> Layer {
415 Layer {
416 url: url.into(),
417 fx: Vec::new(),
418 hue: None,
419 }
420 }
421
422 fn add_fx(&mut self, fx: Effect) {
423 if fx.composes() {
424 if self.fx.iter().filter(|e| **e == fx).count() >= MAX_REPEAT {
425 return;
426 }
427 } else if self.fx.contains(&fx) {
428 return;
429 }
430 let i = self.fx.partition_point(|e| *e <= fx);
431 self.fx.insert(i, fx);
432 }
433
434 fn apply(&mut self, m: &Mods) {
435 for &f in &m.fx {
436 self.add_fx(f);
437 }
438 if m.hue.is_some() {
439 self.hue = m.hue;
440 }
441 }
442
443 fn code_line(&self) -> Option<String> {
445 if self.fx.is_empty() && self.hue.is_none() {
446 return None;
447 }
448 let mut s = String::from("#");
449 s.extend(self.fx.iter().map(|f| f.code()));
450 if let Some(h) = self.hue {
451 s.push('@');
452 s.push_str(&h.to_string());
453 }
454 Some(s)
455 }
456
457 fn set_codes(&mut self, line: &str) {
458 let (codes, hue) = match line.split_once('@') {
459 Some((c, h)) => (c, h.parse::<u16>().ok().map(|h| h % 360)),
460 None => (line, None),
461 };
462 for f in codes.chars().filter_map(Effect::from_code) {
463 self.add_fx(f);
464 }
465 self.hue = hue;
466 }
467}
468
469#[derive(Clone, Debug, PartialEq)]
475pub struct Stack {
476 pub base: String, pub layers: Vec<Layer>,
478}
479
480impl Stack {
481 fn new(base: &str, layer: Layer) -> Stack {
482 Stack {
483 base: base.to_string(),
484 layers: vec![layer],
485 }
486 }
487
488 pub fn wide(&self) -> bool {
491 self.layers
492 .first()
493 .is_some_and(|l| compose(&l.fx).sx.abs() >= 2)
494 }
495
496 pub fn key(&self) -> String {
497 let mut k = String::new();
498 for (i, l) in self.layers.iter().enumerate() {
499 if i > 0 {
500 k.push('\n');
501 }
502 k.push_str(&l.url);
503 if let Some(c) = l.code_line() {
504 k.push('\n');
505 k.push_str(&c);
506 }
507 }
508 k
509 }
510}
511
512pub fn split_key(key: &str) -> Vec<Layer> {
514 let mut layers: Vec<Layer> = Vec::new();
515 for line in key.split('\n') {
516 match line.strip_prefix('#') {
517 Some(codes) => {
518 if let Some(l) = layers.last_mut() {
519 l.set_codes(codes);
520 }
521 }
522 None => layers.push(Layer::new(line)),
523 }
524 }
525 layers
526}
527
528pub fn ref_set(refs: &[Emote]) -> EmoteSet {
532 EmoteSet::from_list(refs.iter().cloned())
533}
534
535pub fn msg_ref(name: &str, url: &str, provider: &str, zero_width: bool) -> Option<Emote> {
539 let name = crate::sanitize::clean(name);
540 let ok_url = url.len() <= 512
541 && url.starts_with("https://")
542 && !url.chars().any(|c| c.is_whitespace() || c.is_control());
543 if name.is_empty() || name.chars().any(char::is_whitespace) || !ok_url {
544 return None;
545 }
546 Some(Emote {
547 name,
548 url: url.to_string(),
549 provider: crate::sanitize::clean(provider),
550 id: String::new(),
551 animated: false,
552 zero_width,
553 global: false,
554 })
555}
556
557fn push_ref(out: &mut Vec<Emote>, e: Option<Emote>) {
558 if let Some(e) = e {
559 if !out.iter().any(|o| o.name == e.name) {
560 out.push(e);
561 }
562 }
563}
564
565pub fn twitch_native(tag: &str, text: &str) -> Vec<Emote> {
570 let chars: Vec<char> = text.chars().collect();
571 let mut out = Vec::new();
572 for part in tag.split('/') {
573 let Some((id, pos)) = part.split_once(':') else {
574 continue;
575 };
576 if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
579 continue;
580 }
581 let Some((s, e)) = pos
582 .split(',')
583 .next()
584 .and_then(|p| p.split_once('-'))
585 .and_then(|(s, e)| Some((s.parse::<usize>().ok()?, e.parse::<usize>().ok()?)))
586 else {
587 continue;
588 };
589 if s > e || e >= chars.len() {
590 continue;
591 }
592 let name: String = chars[s..=e].iter().collect();
593 let url = format!("https://static-cdn.jtvnw.net/emoticons/v2/{id}/default/dark/2.0");
594 push_ref(&mut out, msg_ref(&name, &url, "twitch", false));
595 }
596 out
597}
598
599pub fn kick_native(text: &str) -> (String, Vec<Emote>) {
603 const OPEN: &str = "[emote:";
604 let mut out = String::with_capacity(text.len());
605 let mut refs = Vec::new();
606 let mut rest = text;
607 while let Some(i) = rest.find(OPEN) {
608 out.push_str(&rest[..i]);
609 let after = &rest[i + OPEN.len()..];
610 let token = after.split_once(']').and_then(|(body, tail)| {
611 let (id, name) = body.split_once(':')?;
612 let url = format!("https://files.kick.com/emotes/{id}/fullsize");
613 let ok = !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit());
614 ok.then(|| msg_ref(name, &url, "kick", false).map(|e| (e, tail)))?
615 });
616 match token {
617 Some((e, tail)) => {
618 if !out.is_empty() && !out.ends_with(char::is_whitespace) {
619 out.push(' ');
620 }
621 out.push_str(&e.name);
622 if tail.chars().next().is_some_and(|c| !c.is_whitespace()) {
623 out.push(' ');
624 }
625 push_ref(&mut refs, Some(e));
626 rest = tail;
627 }
628 None => {
629 out.push_str(OPEN);
630 rest = after;
631 }
632 }
633 }
634 out.push_str(rest);
635 (out, refs)
636}
637
638#[derive(Clone, Debug, PartialEq)]
640pub enum Segment {
641 Text(String),
642 Stack(Stack),
643}
644
645struct Lookup<'a> {
651 set: &'a EmoteSet,
652 refs: &'a EmoteSet,
653}
654
655impl<'a> Lookup<'a> {
656 fn get(&self, name: &str) -> Option<&'a Emote> {
657 match self.refs.get(name) {
658 Some(r) if matches!(r.provider.as_str(), "twitch" | "kick") => {
659 self.set.get(name).or(Some(r))
660 }
661 Some(r) => Some(r),
662 None => self.set.get(name),
663 }
664 }
665}
666
667struct Resolved<'a> {
669 emote: &'a Emote,
670 overlay: bool,
672 mods: Mods,
674}
675
676fn resolve<'a>(word: &str, look: &Lookup<'a>) -> Option<Resolved<'a>> {
681 if let Some(e) = look.get(word) {
682 return Some(Resolved {
683 emote: e,
684 overlay: e.zero_width,
685 mods: Mods::default(),
686 });
687 }
688 if let Some(base) = word.strip_suffix('0').filter(|b| !b.is_empty()) {
689 if let Some(e) = look.get(base) {
690 return Some(Resolved {
691 emote: e,
692 overlay: true,
693 mods: Mods::default(),
694 });
695 }
696 }
697 if !word.contains('!') && !word.contains("ffz") {
700 return None;
701 }
702 for (i, _) in word.char_indices().rev() {
704 if i < 2 {
705 break;
706 }
707 let (base, rest) = word.split_at(i);
708 if let Some(mods) = peel_chain(rest) {
709 if let Some(e) = look.get(base) {
710 return Some(Resolved {
711 emote: e,
712 overlay: e.zero_width || mods.zero,
713 mods,
714 });
715 }
716 }
717 }
718 let mut mods = Mods::default();
719 let mut rem = word;
720 while let Some((k, t)) = tok_prefix(rem, true).filter(|(k, _)| rem.len() > k.len()) {
721 mods.push_tok(t);
722 rem = &rem[k.len()..];
723 }
724 if rem.len() < word.len() {
725 if let Some(e) = look.get(rem) {
726 return Some(Resolved {
727 emote: e,
728 overlay: e.zero_width || mods.zero,
729 mods,
730 });
731 }
732 }
733 None
734}
735
736fn emote_ahead(words: &[&str], from: usize, look: &Lookup) -> bool {
739 for w in &words[from.min(words.len())..] {
740 if is_modifier(w, look) {
741 continue;
742 }
743 return resolve(w, look).is_some();
744 }
745 false
746}
747
748fn is_modifier(word: &str, look: &Lookup) -> bool {
751 classify(word).is_some_and(|c| c.exact || look.get(word).is_none())
752}
753
754pub fn segments(content: &str, set: &EmoteSet) -> Vec<Segment> {
768 segments_with(content, set, &EmoteSet::new())
769}
770
771pub fn segments_with(content: &str, set: &EmoteSet, refs: &EmoteSet) -> Vec<Segment> {
773 let look = Lookup { set, refs };
774 let words: Vec<&str> = content.split_whitespace().collect();
775 let mut out: Vec<Segment> = Vec::new();
776 let mut text = String::new();
777 let mut pending = Mods::default();
780 let mut pending_words: Vec<&str> = Vec::new();
781
782 fn push_text(text: &mut String, w: &str) {
783 if !text.is_empty() {
784 text.push(' ');
785 }
786 text.push_str(w);
787 }
788
789 for (i, &word) in words.iter().enumerate() {
790 if let Some(c) = classify(word).filter(|c| c.exact || look.get(word).is_none()) {
791 let ahead = emote_ahead(&words, i + 1, &look);
792 let back = text.is_empty() && matches!(out.last(), Some(Segment::Stack(_)));
794 let back_ok = back && (!c.mods.fx.is_empty() || c.mods.hue.is_some());
797 let fwd = if c.forward { ahead } else { !back_ok && ahead };
798 if fwd {
799 pending.merge(c.mods);
800 pending_words.push(word);
801 continue;
802 }
803 if back_ok {
804 if let Some(Segment::Stack(s)) = out.last_mut() {
805 if let Some(l) = s.layers.last_mut() {
806 l.apply(&c.mods);
807 continue;
808 }
809 }
810 }
811 for w in pending_words.drain(..) {
813 push_text(&mut text, w);
814 }
815 pending = Mods::default();
816 push_text(&mut text, word);
817 continue;
818 }
819
820 let Some(r) = resolve(word, &look) else {
821 for w in pending_words.drain(..) {
822 push_text(&mut text, w);
823 }
824 pending = Mods::default();
825 push_text(&mut text, word);
826 continue;
827 };
828 let mut mods = std::mem::take(&mut pending);
829 pending_words.clear();
830 mods.merge(r.mods);
831 let overlay = r.overlay || mods.zero;
832 let mut layer = Layer::new(r.emote.url.clone());
833 layer.apply(&mods);
834 if overlay && text.is_empty() {
835 if let Some(Segment::Stack(s)) = out.last_mut() {
836 s.layers.push(layer);
837 continue;
838 }
839 }
840 if !text.is_empty() {
841 out.push(Segment::Text(std::mem::take(&mut text)));
842 }
843 out.push(Segment::Stack(Stack::new(&r.emote.name, layer)));
844 }
845 for w in pending_words.drain(..) {
846 push_text(&mut text, w);
847 }
848 if !text.is_empty() {
849 out.push(Segment::Text(text));
850 }
851 out
852}
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857
858 fn e(name: &str) -> Emote {
859 Emote {
860 name: name.into(),
861 url: format!("https://cdn/{name}.webp"),
862 provider: "7tv".into(),
863 id: name.into(),
864 animated: false,
865 zero_width: false,
866 global: false,
867 }
868 }
869
870 fn zw(name: &str) -> Emote {
871 Emote {
872 zero_width: true,
873 ..e(name)
874 }
875 }
876
877 fn native(name: &str, provider: &str) -> Emote {
878 Emote {
879 url: format!("https://native/{name}"),
880 provider: provider.into(),
881 ..e(name)
882 }
883 }
884
885 fn cast() -> EmoteSet {
887 EmoteSet::from_list([
888 e("KEKW"),
889 e("GAMBA"),
890 e("Kappa"),
891 e("Keepo"),
892 e("lerolero"),
893 zw("notL"),
894 ])
895 }
896
897 fn show_with(msg: &str, set: &EmoteSet, refs: &EmoteSet) -> String {
900 segments_with(msg, set, refs)
901 .iter()
902 .map(|s| match s {
903 Segment::Text(t) => format!("'{t}'"),
904 Segment::Stack(st) => st
905 .layers
906 .iter()
907 .map(|l| {
908 let name = l.url.rsplit('/').next().unwrap();
909 let name = name.strip_suffix(".webp").unwrap_or(name);
910 let codes = l.code_line().unwrap_or_default();
911 match codes.strip_prefix('#') {
912 Some(c) => format!("{name}[{c}]"),
913 None => name.to_string(),
914 }
915 })
916 .collect::<Vec<_>>()
917 .join("+"),
918 })
919 .collect::<Vec<_>>()
920 .join(" ")
921 }
922
923 fn show(msg: &str) -> String {
924 show_with(msg, &cast(), &EmoteSet::new())
925 }
926
927 #[track_caller]
928 fn table(cases: &[(&str, &str)]) {
929 for (input, want) in cases {
930 assert_eq!(show(input), *want, "input: {input}");
931 }
932 }
933
934 #[test]
935 fn channel_emote_overrides_global_regardless_of_arrival_order() {
936 let mut set = EmoteSet::new();
938 let mut globals = EmoteSet::from_list([e("Kappa")]);
939 globals.mark_global();
940 set.merge(globals.clone());
941 set.merge(EmoteSet::from_list([Emote {
942 provider: "bttv".into(),
943 ..e("Kappa")
944 }]));
945 assert_eq!(set.get("Kappa").unwrap().provider, "bttv");
946 let mut set = EmoteSet::from_list([Emote {
948 provider: "bttv".into(),
949 ..e("Kappa")
950 }]);
951 set.merge(globals);
952 assert_eq!(set.get("Kappa").unwrap().provider, "bttv");
953 assert_eq!(set.len(), 1);
954 }
955
956 #[test]
957 fn first_insert_wins_on_collision() {
958 let set = EmoteSet::from_list([
959 Emote {
960 provider: "7tv".into(),
961 ..e("Kappa")
962 },
963 Emote {
964 provider: "bttv".into(),
965 ..e("Kappa")
966 },
967 ]);
968 assert_eq!(set.get("Kappa").unwrap().provider, "7tv");
969 assert_eq!(set.len(), 1);
970 }
971
972 #[test]
973 fn text_and_emotes_coalesce() {
974 table(&[
975 (
976 "lol KEKW that GAMBA moment",
977 "'lol' KEKW 'that' GAMBA 'moment'",
978 ),
979 ("a b\tc", "'a b c'"),
980 ("", ""),
981 ]);
982 }
983
984 #[test]
985 fn bttv_binds_forward() {
986 table(&[
987 ("w!KEKW", "KEKW[w]"),
988 ("w! KEKW", "KEKW[w]"),
989 ("w!h!KEKW", "KEKW[xw]"), ("x! KEKW", "KEKW[x]"),
991 ("y! KEKW", "KEKW[y]"),
992 ("v! KEKW", "KEKW[y]"),
993 ("l! KEKW", "KEKW[l]"),
994 ("r! KEKW", "KEKW[r]"),
995 ("c! KEKW", "KEKW[c]"),
996 ("p! KEKW", "KEKW[p]"),
997 ("s! KEKW", "KEKW[s]"),
998 ("Kappa c! Keepo", "Kappa Keepo[c]"),
1000 ("c! ffzX Kappa", "Kappa[xc]"),
1002 ("w! h! KEKW", "KEKW[xw]"),
1003 ]);
1004 }
1005
1006 #[test]
1007 fn ffz_binds_backward() {
1008 table(&[
1009 ("KEKW ffzX", "KEKW[x]"),
1010 ("KEKW ffzY", "KEKW[y]"),
1011 ("KEKW ffzW", "KEKW[w]"),
1012 ("KEKW ffzWide", "KEKW[w]"),
1013 ("KEKW ffzTall", "KEKW[t]"),
1014 ("KEKW ffzCursed", "KEKW[c]"),
1015 ("KEKW ffzHyper", "KEKW[H]"),
1016 ("KEKW ffzRainbow", "KEKW[R]"),
1017 ("KEKW ffzBounce", "KEKW[b]"),
1018 ("KEKW ffzJam", "KEKW[j]"),
1019 ("KEKW ffzSlide", "KEKW[d]"),
1020 ("KEKW ffzArrive", "KEKW[a]"),
1021 ("KEKW ffzLeave", "KEKW[e]"),
1022 ("KEKW ffzSpin", "KEKW[o]"),
1023 ("KEKW ffzX ffzRainbow", "KEKW[xR]"),
1024 ("Kappa ffzX Keepo", "Kappa[x] Keepo"),
1026 ("GAMBA notL ffzX", "GAMBA+notL[x]"),
1028 ]);
1029 }
1030
1031 #[test]
1032 fn fallback_direction_when_canonical_side_has_no_emote() {
1033 table(&[
1034 ("Kappa w!", "Kappa[w]"), ("ffzX Kappa", "Kappa[x]"), ("Kappa c!#00f", "Kappa[@240]"),
1037 ]);
1038 }
1039
1040 #[test]
1041 fn orphan_modifier_stays_literal() {
1042 table(&[
1043 ("w! nothing here", "'w! nothing here'"),
1044 ("lol ffzX", "'lol ffzX'"),
1045 ("KEKW lol ffzX", "KEKW 'lol ffzX'"), ("w! ffzX", "'w! ffzX'"),
1047 ("c!#00f", "'c!#00f'"),
1048 ("z!", "'z!'"),
1049 ("hi w!h!ffzX", "'hi w!h!ffzX'"),
1050 ]);
1051 }
1052
1053 #[test]
1054 fn glued_suffixes() {
1055 table(&[
1056 ("Kappaw!", "Kappa[w]"),
1057 ("KappaffzX", "Kappa[x]"),
1058 ("KappaffzRainbow", "Kappa[R]"),
1059 ("KappaffzLeave", "Kappa[e]"),
1060 ("Kappac!#0f0", "Kappa[@120]"),
1061 ("Kappac!888", "Kappa[@0]"),
1062 ("Kappaw!h!ffzX", "Kappa[xxw]"),
1063 ("Kappaw!c!#00f", "Kappa[w@240]"),
1064 ("GAMBA notLw!", "GAMBA+notL[w]"),
1066 ]);
1067 }
1068
1069 #[test]
1070 fn glued_prefixes_still_work() {
1071 table(&[("w!h!KEKW", "KEKW[xw]"), ("z!KEKW", "KEKW")]);
1072 }
1073
1074 #[test]
1075 fn chained_words_follow_the_trailing_bang() {
1076 table(&[
1077 ("Kappa w!h!ffzX", "Kappa[xxw]"),
1079 ("Kappa Keepo w!h!ffzX", "Kappa Keepo[xxw]"),
1080 ("Kappa w!h! Keepo", "Kappa Keepo[xw]"),
1082 ("w!c!#00fh! Kappa", "Kappa[xw@240]"),
1083 ]);
1084 }
1085
1086 #[test]
1087 fn hex_colors_tint_and_latest_wins() {
1088 table(&[
1089 ("c!#f00 Kappa", "Kappa[@0]"),
1090 ("c!#0f0 Kappa", "Kappa[@120]"),
1091 ("c!#0000ff Kappa", "Kappa[@240]"),
1092 ("c!00f Kappa", "Kappa[@240]"), ("c!#f00 c!#00f Kappa", "Kappa[@240]"),
1094 ]);
1095 assert_eq!(hex_hue("ff8700"), Some(32));
1096 assert_eq!(hex_hue("zzz"), None);
1097 assert_eq!(hex_hue("12"), None);
1098 }
1099
1100 #[test]
1101 fn composition_per_hscomposeall() {
1102 table(&[
1104 ("w! w! KEKW", "KEKW[ww]"),
1105 ("KEKW ffzRainbow ffzRainbow", "KEKW[R]"),
1106 ("KEKW ffzHyper ffzHyper s!", "KEKW[Hs]"),
1107 ("w!w!w!w!w!w!KEKW", "KEKW[wwww]"), ]);
1109 let g = |fx: &[Effect]| compose(fx);
1110 assert_eq!((g(&[]).sx, g(&[]).sy, g(&[]).rot), (1, 1, 0));
1111 let wide3 = g(&[Effect::Wide; 3]);
1112 assert_eq!(wide3.sx, 4); assert_eq!(g(&[Effect::FlipX, Effect::FlipX]).sx, 1); assert_eq!(g(&[Effect::FlipX, Effect::Wide]).sx, -2);
1115 assert_eq!(g(&[Effect::Tall]).sy, 2);
1116 assert_eq!(g(&[Effect::RotateL, Effect::RotateL]).rot, 180);
1117 assert_eq!(g(&[Effect::RotateL, Effect::RotateR]).rot, 0);
1118 assert_eq!(g(&[Effect::RotateL]).rot, 270);
1119 assert_eq!(g(&[Effect::RotateR; 4]).rot, 0);
1120 }
1121
1122 #[test]
1123 fn wide_is_the_base_layers_footprint() {
1124 let set = cast();
1125 let wide = |m: &str| match &segments(m, &set)[0] {
1126 Segment::Stack(s) => s.wide(),
1127 _ => panic!("no stack"),
1128 };
1129 assert!(wide("w! KEKW"));
1130 assert!(wide("KEKW ffzWide"));
1131 assert!(!wide("KEKW ffzTall"));
1132 assert!(wide("w! w! w! KEKW")); assert!(wide("w! h! KEKW")); assert!(!wide("GAMBA notL ffzW")); assert!(!wide("KEKW"));
1136 }
1137
1138 #[test]
1139 fn zero_width_overlays_stack() {
1140 table(&[
1141 ("GAMBA notL", "GAMBA+notL"),
1142 ("GAMBA notL notL", "GAMBA+notL+notL"),
1143 ("notL", "notL"), ("GAMBA lol notL", "GAMBA 'lol' notL"),
1145 ("GAMBA w! notL", "GAMBA+notL[w]"),
1146 ("GAMBA notL w!", "GAMBA+notL[w]"),
1147 ]);
1148 }
1149
1150 #[test]
1151 fn z_forces_overlay() {
1152 table(&[
1153 ("GAMBA z!KEKW", "GAMBA+KEKW"),
1154 ("GAMBA z! KEKW", "GAMBA+KEKW"),
1155 ("GAMBA z!w!KEKW", "GAMBA+KEKW[w]"),
1156 ("GAMBA z!", "GAMBA 'z!'"), ]);
1158 }
1159
1160 #[test]
1161 fn name0_overlay_convention() {
1162 table(&[
1163 ("GAMBA lerolero0", "GAMBA+lerolero"),
1164 ("lerolero0", "lerolero"), ]);
1166 let set = EmoteSet::from_list([e("GAMBA"), e("lerolero"), e("lerolero0")]);
1168 assert_eq!(
1169 show_with("GAMBA lerolero0", &set, &EmoteSet::new()),
1170 "GAMBA lerolero0"
1171 );
1172 assert_eq!(show("nope0"), "'nope0'");
1174 assert_eq!(show("0"), "'0'");
1175 }
1176
1177 #[test]
1178 fn exact_emote_name_beats_chain_parse() {
1179 let set = EmoteSet::from_list([e("h!i"), e("w!h!")]);
1180 assert_eq!(show_with("h!i", &set, &EmoteSet::new()), "h!i");
1181 assert_eq!(show_with("w!h!", &set, &EmoteSet::new()), "w!h!");
1183 }
1184
1185 #[test]
1186 fn refs_outrank_the_set_but_native_yields_to_it() {
1187 let set = EmoteSet::from_list([e("Kappa")]);
1188 let heat = Emote {
1189 url: "https://hs/Kappa".into(),
1190 provider: "heatsync".into(),
1191 ..e("Kappa")
1192 };
1193 let refs = EmoteSet::from_list([heat]);
1195 assert_eq!(show_with("Kappa", &set, &refs), "Kappa");
1196 match &segments_with("Kappa", &set, &refs)[0] {
1197 Segment::Stack(s) => assert_eq!(s.layers[0].url, "https://hs/Kappa"),
1198 _ => panic!(),
1199 }
1200 let refs = EmoteSet::from_list([native("Kappa", "twitch")]);
1202 match &segments_with("Kappa", &set, &refs)[0] {
1203 Segment::Stack(s) => assert_eq!(s.layers[0].url, "https://cdn/Kappa.webp"),
1204 _ => panic!(),
1205 }
1206 let refs = EmoteSet::from_list([native("SubEmote", "twitch")]);
1208 match &segments_with("w! SubEmote ffzX", &set, &refs)[0] {
1209 Segment::Stack(s) => {
1210 assert_eq!(s.layers[0].url, "https://native/SubEmote");
1211 assert_eq!(s.layers[0].fx, vec![Effect::FlipX, Effect::Wide]);
1212 }
1213 _ => panic!(),
1214 }
1215 }
1216
1217 #[test]
1218 fn keys_are_unique_per_effect_and_roundtrip() {
1219 let set = cast();
1220 let mut keys = std::collections::HashSet::new();
1221 for (name, _) in TOKENS.iter().filter(|(k, _)| *k != "z!") {
1222 let msg = if name.ends_with('!') {
1223 format!("{name} KEKW")
1224 } else {
1225 format!("KEKW {name}")
1226 };
1227 match &segments(&msg, &set)[0] {
1228 Segment::Stack(s) => keys.insert(s.key()),
1229 _ => panic!("{msg}"),
1230 };
1231 }
1232 assert_eq!(keys.len(), 17);
1234 assert_eq!(CODES.len(), 17);
1235 let codes: std::collections::HashSet<_> = CODES.iter().map(|c| c.1).collect();
1236 assert_eq!(codes.len(), 17, "stack-key codes are unique");
1237 }
1238
1239 #[test]
1240 fn key_roundtrips_through_split_key() {
1241 let set = cast();
1242 let s = match &segments("w!GAMBA notL ffzCursed c!#0f0", &set)[0] {
1243 Segment::Stack(s) => s.clone(),
1244 _ => panic!(),
1245 };
1246 assert_eq!(
1247 s.key(),
1248 "https://cdn/GAMBA.webp\n#w\nhttps://cdn/notL.webp\n#c@120"
1249 );
1250 assert_eq!(split_key(&s.key()), s.layers);
1251 let plain = match &segments("GAMBA notL", &set)[0] {
1253 Segment::Stack(s) => s.key(),
1254 _ => panic!(),
1255 };
1256 assert_eq!(plain, "https://cdn/GAMBA.webp\nhttps://cdn/notL.webp");
1257 assert_eq!(
1258 split_key("https://x/a\n#xw@7")[0].fx,
1259 vec![Effect::FlipX, Effect::Wide]
1260 );
1261 assert_eq!(split_key("https://x/a\n#xw@7")[0].hue, Some(7));
1262 assert!(split_key("#xw").is_empty());
1264 }
1265
1266 #[test]
1267 fn twitch_native_positions_name_the_covered_text() {
1268 let u =
1269 |id: &str| format!("https://static-cdn.jtvnw.net/emoticons/v2/{id}/default/dark/2.0");
1270 for (tag, text, want) in [
1271 ("25:0-4", "Kappa hi", vec![("Kappa", "25")]),
1272 ("25:0-4,12-16", "Kappa hi Kappa", vec![("Kappa", "25")]),
1273 (
1274 "25:0-4/1902:6-10",
1275 "Kappa Keepo",
1276 vec![("Kappa", "25"), ("Keepo", "1902")],
1277 ),
1278 (
1279 "emotesv2_ab12:3-5",
1280 "hi LUL",
1281 vec![("LUL", "emotesv2_ab12")],
1282 ),
1283 ("25:3-7", "\u{1f600}\u{1f600} Kappa", vec![("Kappa", "25")]),
1285 ("25:0-4/", "Kappa hi", vec![("Kappa", "25")]),
1286 ("", "Kappa", vec![]),
1287 ] {
1288 let got: Vec<(String, String)> = twitch_native(tag, text)
1289 .into_iter()
1290 .map(|e| (e.name, e.url))
1291 .collect();
1292 let want: Vec<(String, String)> =
1293 want.into_iter().map(|(n, i)| (n.into(), u(i))).collect();
1294 assert_eq!(got, want, "{tag} / {text}");
1295 }
1296 }
1297
1298 #[test]
1299 fn twitch_native_rejects_bad_input() {
1300 for (tag, text) in [
1301 ("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"), ] {
1308 let got = twitch_native(tag, text);
1309 assert!(got.is_empty(), "{tag}: {got:?}");
1310 }
1311 }
1312
1313 #[test]
1314 fn kick_native_tokens_become_names_and_refs() {
1315 let (text, refs) = kick_native("hi [emote:37226:KEKW] there");
1316 assert_eq!(text, "hi KEKW there");
1317 assert_eq!(refs.len(), 1);
1318 assert_eq!(refs[0].name, "KEKW");
1319 assert_eq!(refs[0].url, "https://files.kick.com/emotes/37226/fullsize");
1320 assert_eq!(refs[0].provider, "kick");
1321 assert_eq!(kick_native("a[emote:1:X]b").0, "a X b");
1323 assert_eq!(kick_native("[emote:1:X][emote:2:Y]").0, "X Y");
1324 assert_eq!(kick_native("[emote:1:X][emote:2:Y]").1.len(), 2);
1325 assert_eq!(kick_native("[emote:1:X] [emote:1:X]").1.len(), 1);
1327 for bad in [
1329 "[emote:abc:X]",
1330 "[emote::X]",
1331 "[emote:1:]",
1332 "[emote:1:X Y]",
1333 "[emote:1:X",
1334 "[emote:../x:X]",
1335 "[emote:12]",
1336 ] {
1337 let (t, r) = kick_native(bad);
1338 assert_eq!(t, bad, "{bad}");
1339 assert!(r.is_empty(), "{bad}");
1340 }
1341 assert_eq!(kick_native("plain").0, "plain");
1342 }
1343
1344 #[test]
1345 fn msg_ref_guards_the_trust_boundary() {
1346 assert!(msg_ref("KEKW", "https://cdn/k.webp", "7tv", false).is_some());
1347 for (n, u) in [
1348 ("", "https://cdn/k"),
1349 ("a b", "https://cdn/k"),
1350 ("KEKW", "http://cdn/k"),
1351 ("KEKW", "javascript:x"),
1352 ("KEKW", "https://cdn/k k"),
1353 ("KEKW", "https://cdn/k\u{1b}[2J"),
1354 ] {
1355 assert!(msg_ref(n, u, "x", false).is_none(), "{n} {u}");
1356 }
1357 assert_eq!(
1359 msg_ref("KE\u{1b}KW", "https://cdn/k", "x", false)
1360 .unwrap()
1361 .name,
1362 "KEKW"
1363 );
1364 }
1365
1366 #[test]
1367 fn native_refs_drive_a_full_parse() {
1368 let set = cast();
1369 let (text, refs) = kick_native("[emote:9:SubOnly] w! KEKW");
1370 let r = ref_set(&refs);
1371 let segs = segments_with(&text, &set, &r);
1372 let bases: Vec<&str> = segs
1373 .iter()
1374 .filter_map(|s| match s {
1375 Segment::Stack(st) => Some(st.base.as_str()),
1376 _ => None,
1377 })
1378 .collect();
1379 assert_eq!(bases, vec!["SubOnly", "KEKW"]);
1380 match &segs[0] {
1381 Segment::Stack(st) => {
1382 assert_eq!(st.layers[0].url, "https://files.kick.com/emotes/9/fullsize")
1383 }
1384 _ => panic!(),
1385 }
1386 }
1387
1388 #[test]
1389 fn parses_real_api_json() {
1390 let json = r#"{"channel":"xqc","emotes":[
1391 {"name":"GAMBA","url":"https://cdn.7tv.app/emote/x/1x.webp","provider":"7tv","id":"x","animated":true}
1392 ],"count":1}"#;
1393 let resp: EmoteSetResponse = serde_json::from_str(json).unwrap();
1394 let set = EmoteSet::from_list(resp.emotes);
1395 assert!(set.get("GAMBA").unwrap().animated);
1396 }
1397}