1use std::collections::HashMap;
8
9use serde::Deserialize;
10
11#[derive(Clone, Debug, 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}
24
25#[derive(Debug, Deserialize)]
27pub struct EmoteSetResponse {
28 #[serde(default)]
29 pub emotes: Vec<Emote>,
30}
31
32#[derive(Clone, Debug, Default)]
34pub struct EmoteSet {
35 map: HashMap<String, Emote>,
36}
37
38impl EmoteSet {
39 pub fn new() -> EmoteSet {
40 EmoteSet::default()
41 }
42
43 pub fn from_list(list: impl IntoIterator<Item = Emote>) -> EmoteSet {
46 let mut set = EmoteSet::new();
47 for e in list {
48 set.map.entry(e.name.clone()).or_insert(e);
49 }
50 set
51 }
52
53 pub fn get(&self, name: &str) -> Option<&Emote> {
54 self.map.get(name)
55 }
56
57 pub fn names(&self) -> impl Iterator<Item = &str> {
59 self.map.keys().map(String::as_str)
60 }
61
62 pub fn len(&self) -> usize {
63 self.map.len()
64 }
65
66 pub fn is_empty(&self) -> bool {
67 self.map.is_empty()
68 }
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
75pub enum Effect {
76 RotateL, RotateR, FlipX, FlipY, Wide, Cursed, Rainbow, Shake, }
85
86impl Effect {
87 pub fn code(self) -> char {
89 match self {
90 Effect::RotateL => 'l',
91 Effect::RotateR => 'r',
92 Effect::FlipX => 'x',
93 Effect::FlipY => 'y',
94 Effect::Wide => 'w',
95 Effect::Cursed => 'c',
96 Effect::Rainbow => 'p',
97 Effect::Shake => 's',
98 }
99 }
100
101 fn from_code(c: char) -> Option<Effect> {
102 Some(match c {
103 'l' => Effect::RotateL,
104 'r' => Effect::RotateR,
105 'x' => Effect::FlipX,
106 'y' => Effect::FlipY,
107 'w' => Effect::Wide,
108 'c' => Effect::Cursed,
109 'p' => Effect::Rainbow,
110 's' => Effect::Shake,
111 _ => return None,
112 })
113 }
114
115 fn from_bttv(c: char) -> Option<Effect> {
118 Some(match c {
119 'w' => Effect::Wide,
120 'h' => Effect::FlipX,
121 'v' => Effect::FlipY,
122 'c' => Effect::Cursed,
123 'l' => Effect::RotateL,
124 'r' => Effect::RotateR,
125 'p' => Effect::Rainbow,
126 's' => Effect::Shake,
127 _ => return None,
128 })
129 }
130
131 fn from_ffz(word: &str) -> Option<Effect> {
133 Some(match word {
134 "ffzX" => Effect::FlipX,
135 "ffzY" => Effect::FlipY,
136 "ffzW" => Effect::Wide,
137 "ffzCursed" => Effect::Cursed,
138 "ffzRainbow" => Effect::Rainbow,
139 "ffzHyper" => Effect::Shake,
140 _ => return None,
141 })
142 }
143}
144
145#[derive(Clone, Debug, PartialEq)]
150pub struct Stack {
151 pub base: String, pub urls: Vec<String>,
153 pub fx: Vec<Effect>, }
155
156impl Stack {
157 fn new(base: &str, url: String) -> Stack {
158 Stack {
159 base: base.to_string(),
160 urls: vec![url],
161 fx: Vec::new(),
162 }
163 }
164
165 fn add_fx(&mut self, fx: Effect) {
166 if let Err(i) = self.fx.binary_search(&fx) {
167 self.fx.insert(i, fx);
168 }
169 }
170
171 pub fn wide(&self) -> bool {
172 self.fx.contains(&Effect::Wide)
173 }
174
175 pub fn key(&self) -> String {
176 let mut k = self.urls.join("\n");
177 if !self.fx.is_empty() {
178 k.push_str("\n#");
179 k.extend(self.fx.iter().map(|f| f.code()));
180 }
181 k
182 }
183}
184
185pub fn split_key(key: &str) -> (Vec<&str>, Vec<Effect>) {
187 let mut urls = Vec::new();
188 let mut fx = Vec::new();
189 for line in key.split('\n') {
190 if let Some(codes) = line.strip_prefix('#') {
191 fx.extend(codes.chars().filter_map(Effect::from_code));
192 } else {
193 urls.push(line);
194 }
195 }
196 (urls, fx)
197}
198
199#[derive(Clone, Debug, PartialEq)]
201pub enum Segment {
202 Text(String),
203 Stack(Stack),
204}
205
206fn strip_bttv(word: &str) -> (Vec<Effect>, bool, &str) {
209 let mut fx = Vec::new();
210 let mut zw = false;
211 let mut rest = word;
212 loop {
213 let mut it = rest.chars();
214 match (it.next(), it.next()) {
215 (Some(c), Some('!')) if c == 'z' || Effect::from_bttv(c).is_some() => {
216 if c == 'z' {
217 zw = true;
218 } else {
219 fx.push(Effect::from_bttv(c).unwrap());
220 }
221 rest = &rest[2..];
222 }
223 _ => return (fx, zw, rest),
224 }
225 }
226}
227
228pub fn segments(content: &str, set: &EmoteSet) -> Vec<Segment> {
241 let mut out: Vec<Segment> = Vec::new();
242 let mut text = String::new();
243 let mut pending: Vec<(Vec<Effect>, bool, String)> = Vec::new();
246
247 fn push_text(text: &mut String, w: &str) {
248 if !text.is_empty() {
249 text.push(' ');
250 }
251 text.push_str(w);
252 }
253 fn flush_pending(text: &mut String, pending: &mut Vec<(Vec<Effect>, bool, String)>) {
254 for (_, _, w) in pending.drain(..) {
255 push_text(text, &w);
256 }
257 }
258 fn flush_text(out: &mut Vec<Segment>, text: &mut String) {
259 if !text.is_empty() {
260 out.push(Segment::Text(std::mem::take(text)));
261 }
262 }
263
264 for word in content.split_whitespace() {
265 if let Some(fx) = Effect::from_ffz(word) {
268 if text.is_empty() && pending.is_empty() {
269 if let Some(Segment::Stack(s)) = out.last_mut() {
270 s.add_fx(fx);
271 continue;
272 }
273 }
274 flush_pending(&mut text, &mut pending);
275 push_text(&mut text, word);
276 continue;
277 }
278
279 let (fx, zw, rest) = if set.get(word).is_some() {
281 (Vec::new(), false, word)
282 } else {
283 strip_bttv(word)
284 };
285 if rest.is_empty() && (!fx.is_empty() || zw) {
286 pending.push((fx, zw, word.to_string()));
288 continue;
289 }
290 match set.get(rest) {
291 Some(e) => {
292 let mut all_fx = fx;
293 let mut force_zw = zw;
294 for (pfx, pzw, _) in pending.drain(..) {
295 all_fx.extend(pfx);
296 force_zw |= pzw;
297 }
298 let overlay = e.zero_width || force_zw;
299 if overlay && all_fx.is_empty() && text.is_empty() {
303 if let Some(Segment::Stack(s)) = out.last_mut() {
304 s.urls.push(e.url.clone());
305 continue;
306 }
307 }
308 flush_text(&mut out, &mut text);
309 let mut s = Stack::new(rest, e.url.clone());
310 for f in all_fx {
311 s.add_fx(f);
312 }
313 out.push(Segment::Stack(s));
314 }
315 None => {
316 flush_pending(&mut text, &mut pending);
317 push_text(&mut text, word);
318 }
319 }
320 }
321 flush_pending(&mut text, &mut pending);
322 flush_text(&mut out, &mut text);
323 out
324}
325
326#[derive(Clone, Debug, PartialEq)]
331pub enum Token {
332 Text(String),
333 Emote(String), }
335
336pub fn tokenize(content: &str, set: &EmoteSet) -> Vec<Token> {
339 let mut out: Vec<Token> = Vec::new();
340 let mut text = String::new();
341 for word in content.split_whitespace() {
342 if set.get(word).is_some() {
343 if !text.is_empty() {
344 out.push(Token::Text(std::mem::take(&mut text)));
345 }
346 out.push(Token::Emote(word.to_string()));
347 } else {
348 if !text.is_empty() {
349 text.push(' ');
350 }
351 text.push_str(word);
352 }
353 }
354 if !text.is_empty() {
355 out.push(Token::Text(text));
356 }
357 out
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 fn e(name: &str) -> Emote {
365 Emote {
366 name: name.into(),
367 url: format!("https://cdn/{name}.webp"),
368 provider: "7tv".into(),
369 id: name.into(),
370 animated: false,
371 zero_width: false,
372 }
373 }
374
375 #[test]
376 fn first_insert_wins_on_collision() {
377 let set = EmoteSet::from_list([
378 Emote {
379 provider: "7tv".into(),
380 ..e("Kappa")
381 },
382 Emote {
383 provider: "bttv".into(),
384 ..e("Kappa")
385 },
386 ]);
387 assert_eq!(set.get("Kappa").unwrap().provider, "7tv");
388 assert_eq!(set.len(), 1);
389 }
390
391 #[test]
392 fn tokenizes_text_and_emotes() {
393 let set = EmoteSet::from_list([e("KEKW"), e("GAMBA")]);
394 let toks = tokenize("lol KEKW that GAMBA moment", &set);
395 assert_eq!(
396 toks,
397 vec![
398 Token::Text("lol".into()),
399 Token::Emote("KEKW".into()),
400 Token::Text("that".into()),
401 Token::Emote("GAMBA".into()),
402 Token::Text("moment".into()),
403 ]
404 );
405 }
406
407 #[test]
408 fn segments_text_and_emotes() {
409 let set = EmoteSet::from_list([e("KEKW"), e("GAMBA")]);
410 let segs = segments("lol KEKW that GAMBA moment", &set);
411 assert_eq!(
412 segs,
413 vec![
414 Segment::Text("lol".into()),
415 Segment::Stack(Stack::new("KEKW", "https://cdn/KEKW.webp".into())),
416 Segment::Text("that".into()),
417 Segment::Stack(Stack::new("GAMBA", "https://cdn/GAMBA.webp".into())),
418 Segment::Text("moment".into()),
419 ]
420 );
421 }
422
423 fn zw(name: &str) -> Emote {
424 Emote {
425 zero_width: true,
426 ..e(name)
427 }
428 }
429
430 fn stacks(msg: &str, set: &EmoteSet) -> Vec<Stack> {
431 segments(msg, set)
432 .into_iter()
433 .filter_map(|s| match s {
434 Segment::Stack(s) => Some(s),
435 _ => None,
436 })
437 .collect()
438 }
439
440 #[test]
441 fn zero_width_overlays_previous_base() {
442 let set = EmoteSet::from_list([e("GAMBA"), zw("notL")]);
443 let s = stacks("GAMBA notL", &set);
444 assert_eq!(s.len(), 1);
445 assert_eq!(
446 s[0].urls,
447 vec!["https://cdn/GAMBA.webp", "https://cdn/notL.webp"]
448 );
449 assert_eq!(s[0].key(), "https://cdn/GAMBA.webp\nhttps://cdn/notL.webp");
450 }
451
452 #[test]
453 fn bttv_attached_prefix_chains() {
454 let set = EmoteSet::from_list([e("KEKW")]);
455 let s = stacks("w!h!KEKW", &set);
456 assert_eq!(s[0].fx, vec![Effect::FlipX, Effect::Wide]); assert!(s[0].wide());
458 assert_eq!(s[0].key(), "https://cdn/KEKW.webp\n#xw");
459 }
460
461 #[test]
462 fn bttv_bare_prefix_applies_to_next_emote() {
463 let set = EmoteSet::from_list([e("KEKW")]);
464 let s = stacks("w! KEKW", &set);
465 assert_eq!(s[0].fx, vec![Effect::Wide]);
466 }
467
468 #[test]
469 fn unused_modifier_degrades_to_text() {
470 let set = EmoteSet::from_list([e("KEKW")]);
471 let segs = segments("w! nothing here", &set);
472 assert_eq!(segs, vec![Segment::Text("w! nothing here".into())]);
473 }
474
475 #[test]
476 fn z_forces_overlay() {
477 let set = EmoteSet::from_list([e("GAMBA"), e("KEKW")]);
478 let s = stacks("GAMBA z!KEKW", &set);
479 assert_eq!(s.len(), 1);
480 assert_eq!(s[0].urls.len(), 2);
481 }
482
483 #[test]
484 fn ffz_effect_words_attach_to_previous_stack() {
485 let set = EmoteSet::from_list([e("KEKW")]);
486 let s = stacks("KEKW ffzX ffzRainbow", &set);
487 assert_eq!(s[0].fx, vec![Effect::FlipX, Effect::Rainbow]);
488 }
489
490 #[test]
491 fn ffz_effect_needs_adjacency() {
492 let set = EmoteSet::from_list([e("KEKW")]);
493 let segs = segments("KEKW lol ffzX", &set);
494 assert_eq!(
495 segs,
496 vec![
497 Segment::Stack(Stack::new("KEKW", "https://cdn/KEKW.webp".into())),
498 Segment::Text("lol ffzX".into()),
499 ]
500 );
501 }
502
503 #[test]
504 fn exact_emote_name_beats_prefix_parse() {
505 let set = EmoteSet::from_list([e("h!i")]);
506 let s = stacks("h!i", &set);
507 assert_eq!(s[0].base, "h!i");
508 assert!(s[0].fx.is_empty());
509 }
510
511 #[test]
512 fn key_roundtrips_through_split_key() {
513 let set = EmoteSet::from_list([e("GAMBA"), zw("notL")]);
514 let s = stacks("w!GAMBA notL ffzCursed", &set);
515 let key = s[0].key();
516 let (urls, fx) = split_key(&key);
517 assert_eq!(
518 urls,
519 vec!["https://cdn/GAMBA.webp", "https://cdn/notL.webp"]
520 );
521 assert_eq!(fx, vec![Effect::Wide, Effect::Cursed]);
522 }
523
524 #[test]
525 fn parses_real_api_json() {
526 let json = r#"{"channel":"xqc","emotes":[
527 {"name":"GAMBA","url":"https://cdn.7tv.app/emote/x/1x.webp","provider":"7tv","id":"x","animated":true}
528 ],"count":1}"#;
529 let resp: EmoteSetResponse = serde_json::from_str(json).unwrap();
530 let set = EmoteSet::from_list(resp.emotes);
531 assert!(set.get("GAMBA").unwrap().animated);
532 }
533}