Skip to main content

heatsync_core/
emote.rs

1//! emote resolution. mirrors the server: /api/channel/:c/emotes returns a
2//! precedence-ordered (7tv > bttv > ffz > twitch > kick) deduped list, so we
3//! insert first-wins into a name→emote map and word-match message text against
4//! it. modifiers (BTTV `w!`-style prefixes / FFZ `ffzX`-style effect words) are
5//! parsed here into per-layer [`Effect`] lists; the pixel work happens at render.
6
7use std::collections::HashMap;
8
9use serde::Deserialize;
10
11/// one emote as the HeatSync emote API returns it.
12#[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    /// came from the pooled global set (/api/emotes) rather than a channel's
24    /// own set — channel emotes of the same name override it. never serialized.
25    #[serde(skip)]
26    pub global: bool,
27}
28
29/// the /api/channel/:c/emotes envelope (only the bits we need).
30#[derive(Debug, Deserialize)]
31pub struct EmoteSetResponse {
32    #[serde(default)]
33    pub emotes: Vec<Emote>,
34}
35
36/// name → emote lookup, precedence-respecting (first insert wins).
37#[derive(Clone, Debug, Default)]
38pub struct EmoteSet {
39    map: HashMap<String, Emote>,
40}
41
42/// the channel owner's own heatsync emotes (`/api/emotes/user/<channel>`) →
43/// emotes. lenient per row: a row with no name or url is dropped, a missing
44/// provider is an upload, a root-relative url is anchored on `base`.
45pub fn owner_list(v: &serde_json::Value, base: &str) -> Vec<Emote> {
46    let Some(rows) = v.get("emotes").and_then(|e| e.as_array()) else {
47        return Vec::new();
48    };
49    rows.iter()
50        .filter_map(|r| {
51            let s = |k: &str| r.get(k).and_then(|x| x.as_str()).filter(|x| !x.is_empty());
52            let name = s("name")?;
53            let url = s("url")?;
54            let url = if url.starts_with('/') && !url.starts_with("//") {
55                format!("{base}{url}")
56            } else if url.starts_with("https://") {
57                url.to_string()
58            } else {
59                return None;
60            };
61            Some(Emote {
62                name: name.to_string(),
63                url,
64                provider: s("provider").unwrap_or("upload").to_string(),
65                id: s("hash").unwrap_or_default().to_string(),
66                animated: false,
67                zero_width: false,
68                global: false,
69            })
70        })
71        .collect()
72}
73
74impl EmoteSet {
75    pub fn new() -> EmoteSet {
76        EmoteSet::default()
77    }
78
79    /// the shared empty set — "no per-message refs" without allocating one.
80    pub fn none() -> &'static EmoteSet {
81        static NONE: std::sync::OnceLock<EmoteSet> = std::sync::OnceLock::new();
82        NONE.get_or_init(EmoteSet::new)
83    }
84
85    /// build from a channel API list. the server sends the winner of a name
86    /// LAST (`[bttv, 7tv, ffz]` for last-writer-wins clients, so ffz > 7tv >
87    /// bttv), so a later entry replaces an earlier one.
88    pub fn from_list(list: impl IntoIterator<Item = Emote>) -> EmoteSet {
89        let mut set = EmoteSet::new();
90        for e in list {
91            set.map.insert(e.name.clone(), e);
92        }
93        set
94    }
95
96    /// build from the pooled global list (`/api/emotes`), stamped global. its
97    /// row order says nothing about providers, so rank them here: 7tv > bttv >
98    /// ffz > twitch (the extension's global order), losers first.
99    pub fn from_global_list(list: impl IntoIterator<Item = Emote>) -> EmoteSet {
100        const WINNER_FIRST: [&str; 4] = ["7tv", "bttv", "ffz", "twitch"];
101        let rank = |e: &Emote| {
102            WINNER_FIRST
103                .iter()
104                .position(|p| *p == e.provider)
105                .unwrap_or(WINNER_FIRST.len())
106        };
107        let mut list: Vec<Emote> = list.into_iter().collect();
108        // stable, descending rank: the winner ends up last
109        list.sort_by_key(|e| std::cmp::Reverse(rank(e)));
110        let mut set = EmoteSet::from_list(list);
111        set.mark_global();
112        set
113    }
114
115    /// fold another set in, first-wins — a merged (multi-platform) tab pools
116    /// its sources' emotes into one lookup. the one exception: a channel emote
117    /// always replaces a pooled GLOBAL of the same name, whichever arrived
118    /// first — globals fetch in parallel with channel sets, so arrival order
119    /// can't be the precedence rule.
120    pub fn merge(&mut self, other: EmoteSet) {
121        for (k, v) in other.map {
122            match self.map.get(&k) {
123                Some(cur) if cur.global && !v.global => {
124                    self.map.insert(k, v);
125                }
126                Some(_) => {}
127                None => {
128                    self.map.insert(k, v);
129                }
130            }
131        }
132    }
133
134    /// stamp every entry as coming from the pooled global set.
135    pub fn mark_global(&mut self) {
136        for e in self.map.values_mut() {
137            e.global = true;
138        }
139    }
140
141    pub fn get(&self, name: &str) -> Option<&Emote> {
142        self.map.get(name)
143    }
144
145    /// all emote names (unordered) — completion feeds from this.
146    pub fn names(&self) -> impl Iterator<Item = &str> {
147        self.map.keys().map(String::as_str)
148    }
149
150    pub fn len(&self) -> usize {
151        self.map.len()
152    }
153
154    pub fn is_empty(&self) -> bool {
155        self.map.is_empty()
156    }
157}
158
159/// a pixel effect on an emote layer. BTTV prefix modifiers and FFZ effect
160/// words share this vocabulary; the render layer applies them in enum order
161/// (geometry → color → motion), so derived Ord IS the application order.
162#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
163pub enum Effect {
164    RotateL, // l!
165    RotateR, // r!
166    FlipX,   // h! x! / ffzX
167    FlipY,   // v! y! / ffzY
168    Wide,    // w! / ffzW ffzWide — double width
169    Tall,    // ffzTall — double height
170    Cursed,  // c! / ffzCursed — grayscale + darken
171    Hyper,   // ffzHyper — oversaturated + shake
172    Party,   // p! — saturated hue cycle
173    Rainbow, // ffzRainbow — hue cycle (animates even static emotes)
174    Shake,   // s! — jitter (animates even static emotes)
175    Bounce,  // ffzBounce
176    Jam,     // ffzJam — squash/stretch pulse
177    Slide,   // ffzSlide — in from the left
178    Arrive,  // ffzArrive — grow in
179    Leave,   // ffzLeave — shrink out
180    Spin,    // ffzSpin
181}
182
183/// one-letter stack-key codes (stable — keys are cache identities).
184const CODES: [(Effect, char); 17] = [
185    (Effect::RotateL, 'l'),
186    (Effect::RotateR, 'r'),
187    (Effect::FlipX, 'x'),
188    (Effect::FlipY, 'y'),
189    (Effect::Wide, 'w'),
190    (Effect::Tall, 't'),
191    (Effect::Cursed, 'c'),
192    (Effect::Hyper, 'H'),
193    (Effect::Party, 'p'),
194    (Effect::Rainbow, 'R'),
195    (Effect::Shake, 's'),
196    (Effect::Bounce, 'b'),
197    (Effect::Jam, 'j'),
198    (Effect::Slide, 'd'),
199    (Effect::Arrive, 'a'),
200    (Effect::Leave, 'e'),
201    (Effect::Spin, 'o'),
202];
203
204/// a modifier word's meaning: a pixel effect, or `z!` (changes stacking only).
205#[derive(Clone, Copy)]
206enum Tok {
207    Fx(Effect),
208    Zero,
209}
210
211/// the shared word table, same as the extension's HS_MOD_TOKENS. a key ending
212/// in `!` is BTTV-shaped (binds forward); the rest are FFZ (bind backward).
213const TOKENS: [(&str, Tok); 25] = [
214    ("w!", Tok::Fx(Effect::Wide)),
215    ("h!", Tok::Fx(Effect::FlipX)),
216    ("v!", Tok::Fx(Effect::FlipY)),
217    ("z!", Tok::Zero),
218    ("c!", Tok::Fx(Effect::Cursed)),
219    ("l!", Tok::Fx(Effect::RotateL)),
220    ("r!", Tok::Fx(Effect::RotateR)),
221    ("p!", Tok::Fx(Effect::Party)),
222    ("s!", Tok::Fx(Effect::Shake)),
223    ("x!", Tok::Fx(Effect::FlipX)),
224    ("y!", Tok::Fx(Effect::FlipY)),
225    ("ffzX", Tok::Fx(Effect::FlipX)),
226    ("ffzY", Tok::Fx(Effect::FlipY)),
227    ("ffzW", Tok::Fx(Effect::Wide)),
228    ("ffzWide", Tok::Fx(Effect::Wide)),
229    ("ffzTall", Tok::Fx(Effect::Tall)),
230    ("ffzCursed", Tok::Fx(Effect::Cursed)),
231    ("ffzHyper", Tok::Fx(Effect::Hyper)),
232    ("ffzRainbow", Tok::Fx(Effect::Rainbow)),
233    ("ffzBounce", Tok::Fx(Effect::Bounce)),
234    ("ffzJam", Tok::Fx(Effect::Jam)),
235    ("ffzSlide", Tok::Fx(Effect::Slide)),
236    ("ffzArrive", Tok::Fx(Effect::Arrive)),
237    ("ffzLeave", Tok::Fx(Effect::Leave)),
238    ("ffzSpin", Tok::Fx(Effect::Spin)),
239];
240
241/// scales clamp to ±this (extension HS_MOD_MAX_SCALE).
242const MAX_SCALE: i32 = 4;
243/// geometry repeats kept per layer — past this nothing changes (clamped) and
244/// keys stay bounded.
245const MAX_REPEAT: usize = 4;
246
247impl Effect {
248    /// one-letter code used in stack keys.
249    pub fn code(self) -> char {
250        CODES.iter().find(|(e, _)| *e == self).map_or('?', |c| c.1)
251    }
252
253    fn from_code(c: char) -> Option<Effect> {
254        CODES.iter().find(|(_, k)| *k == c).map(|c| c.0)
255    }
256
257    /// geometry composes (scales multiply, rotations add) so repeats count;
258    /// everything else dedupes.
259    fn composes(self) -> bool {
260        self <= Effect::Tall
261    }
262
263    /// motion effects synthesize frames over time, after color.
264    pub fn is_motion(self) -> bool {
265        self >= Effect::Shake
266    }
267}
268
269/// composed geometry of a layer's effects, per hsModComposeAll: scale factors
270/// multiply and clamp to ±4 (negative = mirrored), rotations add.
271#[derive(Clone, Copy, Debug, PartialEq, Eq)]
272pub struct Geometry {
273    pub sx: i32,
274    pub sy: i32,
275    /// clockwise degrees, always a multiple of 90 in 0..360.
276    pub rot: u32,
277}
278
279pub fn compose(fx: &[Effect]) -> Geometry {
280    let (mut sx, mut sy, mut rot) = (1i32, 1i32, 0i32);
281    for f in fx {
282        match f {
283            Effect::Wide => sx = sx.saturating_mul(2),
284            Effect::Tall => sy = sy.saturating_mul(2),
285            Effect::FlipX => sx = -sx,
286            Effect::FlipY => sy = -sy,
287            Effect::RotateL => rot -= 90,
288            Effect::RotateR => rot += 90,
289            _ => {}
290        }
291    }
292    Geometry {
293        sx: sx.clamp(-MAX_SCALE, MAX_SCALE),
294        sy: sy.clamp(-MAX_SCALE, MAX_SCALE),
295        rot: rot.rem_euclid(360) as u32,
296    }
297}
298
299/// hue (degrees) of a 3- or 6-digit hex color, rounded — hsModHexToHue.
300fn hex_hue(hex: &str) -> Option<u16> {
301    let v: Vec<f64> = match hex.len() {
302        3 => hex
303            .chars()
304            .map(|c| c.to_digit(16).map(|d| (d * 17) as f64 / 255.0))
305            .collect::<Option<_>>()?,
306        6 => (0..3)
307            .map(|i| {
308                u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16)
309                    .ok()
310                    .map(|b| b as f64 / 255.0)
311            })
312            .collect::<Option<_>>()?,
313        _ => return None,
314    };
315    let (r, g, b) = (v[0], v[1], v[2]);
316    let (max, min) = (r.max(g).max(b), r.min(g).min(b));
317    let d = max - min;
318    let mut h = 0.0;
319    if d > 0.0 {
320        h = if max == r {
321            ((g - b) / d) % 6.0
322        } else if max == g {
323            (b - r) / d + 2.0
324        } else {
325            (r - g) / d + 4.0
326        } * 60.0;
327        if h < 0.0 {
328            h += 360.0;
329        }
330    }
331    Some(h.round() as u16 % 360)
332}
333
334/// a `c!#hex` color token at the start of `s`: (hue, bytes consumed). 6 digits
335/// preferred over 3, the `#` optional — the extension's peel regex.
336fn color_prefix(s: &str) -> Option<(u16, usize)> {
337    let rest = s.strip_prefix("c!")?;
338    let (hash, rest) = match rest.strip_prefix('#') {
339        Some(r) => (1, r),
340        None => (0, rest),
341    };
342    for n in [6, 3] {
343        if let Some(h) = rest
344            .get(..n)
345            .filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))
346        {
347            return Some((hex_hue(h)?, 2 + hash + n));
348        }
349    }
350    None
351}
352
353/// what a modifier word contributes.
354#[derive(Clone, Debug, Default, PartialEq)]
355struct Mods {
356    fx: Vec<Effect>,
357    zero: bool,
358    hue: Option<u16>,
359}
360
361impl Mods {
362    fn is_empty(&self) -> bool {
363        self.fx.is_empty() && !self.zero && self.hue.is_none()
364    }
365
366    fn push_tok(&mut self, t: Tok) {
367        match t {
368            Tok::Fx(f) => self.fx.push(f),
369            Tok::Zero => self.zero = true,
370        }
371    }
372
373    /// fold later mods in; a later hue wins.
374    fn merge(&mut self, o: Mods) {
375        self.fx.extend(o.fx);
376        self.zero |= o.zero;
377        self.hue = o.hue.or(self.hue);
378    }
379}
380
381fn exact_tok(word: &str) -> Option<Tok> {
382    TOKENS.iter().find(|(k, _)| *k == word).map(|t| t.1)
383}
384
385/// longest token that starts `s`, optionally limited to BTTV-shaped (`!`) ones.
386fn tok_prefix(s: &str, bttv_only: bool) -> Option<(&'static str, Tok)> {
387    TOKENS
388        .iter()
389        .filter(|(k, _)| s.starts_with(k) && (!bttv_only || k.ends_with('!')))
390        .max_by_key(|(k, _)| k.len())
391        .map(|t| (t.0, t.1))
392}
393
394/// peel a word made ENTIRELY of modifier tokens (`w!h!ffzX`, `w!c!#888h!`).
395fn peel_chain(word: &str) -> Option<Mods> {
396    let mut m = Mods::default();
397    let mut rem = word;
398    while !rem.is_empty() {
399        if let Some((hue, n)) = color_prefix(rem) {
400            m.hue = Some(hue);
401            rem = &rem[n..];
402        } else if let Some((k, t)) = tok_prefix(rem, false) {
403            m.push_tok(t);
404            rem = &rem[k.len()..];
405        } else {
406            return None;
407        }
408    }
409    (!m.is_empty()).then_some(m)
410}
411
412/// a classified modifier word.
413struct Modifier {
414    mods: Mods,
415    /// binds to the NEXT emote first (BTTV-shaped), else the previous.
416    forward: bool,
417    /// a single table token / color (vs a peeled chain). only chains defer to
418    /// a real emote of the same name.
419    exact: bool,
420}
421
422fn classify(word: &str) -> Option<Modifier> {
423    if let Some(t) = exact_tok(word) {
424        let mut mods = Mods::default();
425        mods.push_tok(t);
426        return Some(Modifier {
427            mods,
428            forward: word.ends_with('!'),
429            exact: true,
430        });
431    }
432    if let Some((hue, n)) = color_prefix(word) {
433        if n == word.len() {
434            let mods = Mods {
435                hue: Some(hue),
436                ..Mods::default()
437            };
438            // c!#hex is BTTV-shaped — forward first like its siblings.
439            return Some(Modifier {
440                mods,
441                forward: true,
442                exact: true,
443            });
444        }
445    }
446    // chains mirror the extension: direction by the word's trailing `!`.
447    peel_chain(word).map(|mods| Modifier {
448        mods,
449        forward: word.ends_with('!'),
450        exact: false,
451    })
452}
453
454/// one drawable layer of a stack: an emote image plus ITS OWN effects — a
455/// modifier binds to one emote, so `Kappa RainTime w!` widens RainTime only.
456#[derive(Clone, Debug, PartialEq)]
457pub struct Layer {
458    pub url: String,
459    /// sorted (= application order); geometry may repeat, the rest dedupes.
460    pub fx: Vec<Effect>,
461    /// `c!#hex` tint as a hue-rotate in degrees.
462    pub hue: Option<u16>,
463}
464
465impl Layer {
466    fn new(url: impl Into<String>) -> Layer {
467        Layer {
468            url: url.into(),
469            fx: Vec::new(),
470            hue: None,
471        }
472    }
473
474    fn add_fx(&mut self, fx: Effect) {
475        if fx.composes() {
476            if self.fx.iter().filter(|e| **e == fx).count() >= MAX_REPEAT {
477                return;
478            }
479        } else if self.fx.contains(&fx) {
480            return;
481        }
482        let i = self.fx.partition_point(|e| *e <= fx);
483        self.fx.insert(i, fx);
484    }
485
486    fn apply(&mut self, m: &Mods) {
487        for &f in &m.fx {
488            self.add_fx(f);
489        }
490        if m.hue.is_some() {
491            self.hue = m.hue;
492        }
493    }
494
495    /// `#codes@hue` key line, if the layer has any effect.
496    fn code_line(&self) -> Option<String> {
497        if self.fx.is_empty() && self.hue.is_none() {
498            return None;
499        }
500        let mut s = String::from("#");
501        s.extend(self.fx.iter().map(|f| f.code()));
502        if let Some(h) = self.hue {
503            s.push('@');
504            s.push_str(&h.to_string());
505        }
506        Some(s)
507    }
508
509    fn set_codes(&mut self, line: &str) {
510        let (codes, hue) = match line.split_once('@') {
511            Some((c, h)) => (c, h.parse::<u16>().ok().map(|h| h % 360)),
512            None => (line, None),
513        };
514        for f in codes.chars().filter_map(Effect::from_code) {
515            self.add_fx(f);
516        }
517        self.hue = hue;
518    }
519}
520
521/// a renderable emote stack: base emote + zero-width overlays, each with its
522/// own effects. the key doubles as the render cache identity — each layer's
523/// url on its own line, followed by an optional `#codes@hue` line ('\n' and
524/// '#'-first lines can't appear in a real url, so the parse back is
525/// unambiguous).
526#[derive(Clone, Debug, PartialEq)]
527pub struct Stack {
528    pub base: String, // base emote name — the text fallback while loading
529    pub layers: Vec<Layer>,
530}
531
532impl Stack {
533    fn new(base: &str, layer: Layer) -> Stack {
534        Stack {
535            base: base.to_string(),
536            layers: vec![layer],
537        }
538    }
539
540    /// the stack renders at least double width (overlays resize onto the base,
541    /// so only the base layer's geometry sets the footprint).
542    pub fn wide(&self) -> bool {
543        self.layers
544            .first()
545            .is_some_and(|l| compose(&l.fx).sx.abs() >= 2)
546    }
547
548    pub fn key(&self) -> String {
549        let mut k = String::new();
550        for (i, l) in self.layers.iter().enumerate() {
551            if i > 0 {
552                k.push('\n');
553            }
554            k.push_str(&l.url);
555            if let Some(c) = l.code_line() {
556                k.push('\n');
557                k.push_str(&c);
558            }
559        }
560        k
561    }
562}
563
564/// split a stack key back into its layers (render side).
565pub fn split_key(key: &str) -> Vec<Layer> {
566    let mut layers: Vec<Layer> = Vec::new();
567    for line in key.split('\n') {
568        match line.strip_prefix('#') {
569            Some(codes) => {
570                if let Some(l) = layers.last_mut() {
571                    l.set_codes(codes);
572                }
573            }
574            None => layers.push(Layer::new(line)),
575        }
576    }
577    layers
578}
579
580/// the emote refs one message carries: its own name → url bindings, built from
581/// the relay's `hsEmotes`, twitch's `emotes` tag and kick's `[emote:id:name]`
582/// tokens. outrank the channel set when parsed (see [`segments_with`]).
583pub fn ref_set(refs: &[Emote]) -> EmoteSet {
584    EmoteSet::from_list(refs.iter().cloned())
585}
586
587/// one per-message ref, or None when the name can't be a chat word or the url
588/// isn't a plain https link — this is the trust boundary for relay-supplied
589/// emotes (a url ends up fetched by the renderer).
590pub fn msg_ref(name: &str, url: &str, provider: &str, zero_width: bool) -> Option<Emote> {
591    let name = crate::sanitize::clean(name);
592    let ok_url = url.len() <= 512
593        && url.starts_with("https://")
594        && !url.chars().any(|c| c.is_whitespace() || c.is_control());
595    if name.is_empty() || name.chars().any(char::is_whitespace) || !ok_url {
596        return None;
597    }
598    Some(Emote {
599        name,
600        url: url.to_string(),
601        provider: crate::sanitize::clean(provider),
602        id: String::new(),
603        animated: false,
604        zero_width,
605        global: false,
606    })
607}
608
609fn push_ref(out: &mut Vec<Emote>, e: Option<Emote>) {
610    if let Some(e) = e {
611        if !out.iter().any(|o| o.name == e.name) {
612            out.push(e);
613        }
614    }
615}
616
617/// twitch's IRC `emotes` tag (`id:start-end,start-end/id2:…`) → refs, naming
618/// each emote by the text it covers. positions count unicode code points, not
619/// bytes or utf-16 units. a span that is out of range or crosses a space (the
620/// relay altered the text under the positions) is skipped, never guessed.
621pub fn twitch_native(tag: &str, text: &str) -> Vec<Emote> {
622    let chars: Vec<char> = text.chars().collect();
623    let mut out = Vec::new();
624    for part in tag.split('/') {
625        let Some((id, pos)) = part.split_once(':') else {
626            continue;
627        };
628        // ids are alnum / `_` (v2 ids like `emotesv2_abc`) — anything else
629        // would smuggle path or query bits into the cdn url.
630        if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
631            continue;
632        }
633        let Some((s, e)) = pos
634            .split(',')
635            .next()
636            .and_then(|p| p.split_once('-'))
637            .and_then(|(s, e)| Some((s.parse::<usize>().ok()?, e.parse::<usize>().ok()?)))
638        else {
639            continue;
640        };
641        if s > e || e >= chars.len() {
642            continue;
643        }
644        let name: String = chars[s..=e].iter().collect();
645        let url = format!("https://static-cdn.jtvnw.net/emoticons/v2/{id}/default/dark/2.0");
646        push_ref(&mut out, msg_ref(&name, &url, "twitch", false));
647    }
648    out
649}
650
651/// kick's inline `[emote:ID:name]` tokens → the text with each token replaced
652/// by its name (spaced off from neighbors so they read as words) + refs to
653/// kick's cdn. a malformed token is left as typed.
654pub fn kick_native(text: &str) -> (String, Vec<Emote>) {
655    const OPEN: &str = "[emote:";
656    let mut out = String::with_capacity(text.len());
657    let mut refs = Vec::new();
658    let mut rest = text;
659    while let Some(i) = rest.find(OPEN) {
660        out.push_str(&rest[..i]);
661        let after = &rest[i + OPEN.len()..];
662        let token = after.split_once(']').and_then(|(body, tail)| {
663            let (id, name) = body.split_once(':')?;
664            let url = format!("https://files.kick.com/emotes/{id}/fullsize");
665            let ok = !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit());
666            ok.then(|| msg_ref(name, &url, "kick", false).map(|e| (e, tail)))?
667        });
668        match token {
669            Some((e, tail)) => {
670                if !out.is_empty() && !out.ends_with(char::is_whitespace) {
671                    out.push(' ');
672                }
673                out.push_str(&e.name);
674                if tail.chars().next().is_some_and(|c| !c.is_whitespace()) {
675                    out.push(' ');
676                }
677                push_ref(&mut refs, Some(e));
678                rest = tail;
679            }
680            None => {
681                out.push_str(OPEN);
682                rest = after;
683            }
684        }
685    }
686    out.push_str(rest);
687    (out, refs)
688}
689
690/// a laid-out chunk of a message: literal text, or an emote stack.
691#[derive(Clone, Debug, PartialEq)]
692pub enum Segment {
693    Text(String),
694    Stack(Stack),
695}
696
697/// name → emote with per-message refs on top. refs (the sender's inventory
698/// emotes, twitch/kick native ids) win over the channel set — the emote the
699/// sender actually used is the only one that isn't a guess. a platform token
700/// is exact by position, so no same-named set emote ever replaces it.
701struct Lookup<'a> {
702    set: &'a EmoteSet,
703    refs: &'a EmoteSet,
704}
705
706impl<'a> Lookup<'a> {
707    fn get(&self, name: &str) -> Option<&'a Emote> {
708        self.refs.get(name).or_else(|| self.set.get(name))
709    }
710}
711
712/// an emote word resolved to a layer's worth of data.
713struct Resolved<'a> {
714    emote: &'a Emote,
715    /// stacks onto the previous emote instead of starting a new one.
716    overlay: bool,
717    /// effects glued onto the word (`Kappaw!`, `w!Kappa`).
718    mods: Mods,
719}
720
721/// word → emote. a literal name always wins; then the 7tv `Name0` overlay
722/// convention (only when no emote is literally named `Name0`), then glued
723/// modifier suffixes (`Kappaw!`, `KappaffzX`, `Kappac!#888`, chains), then
724/// glued BTTV prefixes (`w!h!Kappa`).
725fn resolve<'a>(word: &str, look: &Lookup<'a>) -> Option<Resolved<'a>> {
726    if let Some(e) = look.get(word) {
727        return Some(Resolved {
728            emote: e,
729            overlay: e.zero_width,
730            mods: Mods::default(),
731        });
732    }
733    if let Some(base) = word.strip_suffix('0').filter(|b| !b.is_empty()) {
734        if let Some(e) = look.get(base) {
735            return Some(Resolved {
736                emote: e,
737                overlay: true,
738                mods: Mods::default(),
739            });
740        }
741    }
742    // every modifier spelling has a `!` or starts `ffz` — skip the peel scans
743    // for the (vast majority) plain words.
744    if !word.contains('!') && !word.contains("ffz") {
745        return None;
746    }
747    // longest base first, at least two chars
748    for (i, _) in word.char_indices().rev() {
749        if i < 2 {
750            break;
751        }
752        let (base, rest) = word.split_at(i);
753        if let Some(mods) = peel_chain(rest) {
754            if let Some(e) = look.get(base) {
755                return Some(Resolved {
756                    emote: e,
757                    overlay: e.zero_width || mods.zero,
758                    mods,
759                });
760            }
761        }
762    }
763    let mut mods = Mods::default();
764    let mut rem = word;
765    while let Some((k, t)) = tok_prefix(rem, true).filter(|(k, _)| rem.len() > k.len()) {
766        mods.push_tok(t);
767        rem = &rem[k.len()..];
768    }
769    if rem.len() < word.len() {
770        if let Some(e) = look.get(rem) {
771            return Some(Resolved {
772                emote: e,
773                overlay: e.zero_width || mods.zero,
774                mods,
775            });
776        }
777    }
778    None
779}
780
781/// is the next non-modifier word an emote? modifiers in between are skipped so
782/// `c! ffzX Kappa` lands both on Kappa; a plain word stops the scan.
783fn emote_ahead(words: &[&str], from: usize, look: &Lookup) -> bool {
784    for w in &words[from.min(words.len())..] {
785        if is_modifier(w, look) {
786            continue;
787        }
788        return resolve(w, look).is_some();
789    }
790    false
791}
792
793/// a modifier word — unless it is exactly an emote's name (only chains yield
794/// to a same-named emote; the shared table tokens win, as in the extension).
795fn is_modifier(word: &str, look: &Lookup) -> bool {
796    classify(word).is_some_and(|c| c.exact || look.get(word).is_none())
797}
798
799/// the emotes a line names by their literal word (refs first, then the set) —
800/// blocked ones included, so a block key can still be aimed at them. in order,
801/// each exact emote once.
802pub fn emotes_in<'a>(content: &str, set: &'a EmoteSet, refs: &'a EmoteSet) -> Vec<&'a Emote> {
803    let look = Lookup { set, refs };
804    let mut out: Vec<&Emote> = Vec::new();
805    for w in content.split_whitespace() {
806        if let Some(e) = look.get(w) {
807            if !out.iter().any(|o| o.url == e.url) {
808                out.push(e);
809            }
810        }
811    }
812    out
813}
814
815/// parse message text into text + emote-stack segments. handles:
816///   - zero-width emotes overlaying the preceding base (`GAMBA notL`) and the
817///     `Name0` overlay convention
818///   - BTTV modifiers (`w! h! v! z! c! l! r! p! s! x! y!`, `c!#hex`) binding
819///     FORWARD to the next emote, bare (`w! KEKW`) or attached (`w!KEKW`)
820///   - FFZ words (`ffzX ffzW ffzRainbow …`) binding BACKWARD to the last
821///     layer of the stack (an overlay, not the base)
822///   - the opposite direction as fallback when the canonical side has no emote
823///   - glued suffixes (`Kappaw!`, `KappaffzX`, `Kappac!#888`) and chains
824///     (`w!h!ffzX`)
825///
826/// a modifier with no emote on either side stays literal text. adjacent text
827/// words coalesce single-spaced. input is assumed already sanitized.
828pub fn segments(content: &str, set: &EmoteSet) -> Vec<Segment> {
829    segments_with(content, set, &EmoteSet::new())
830}
831
832/// [`segments`] with a per-message ref set that outranks `set` — see [`Lookup`].
833pub fn segments_with(content: &str, set: &EmoteSet, refs: &EmoteSet) -> Vec<Segment> {
834    let look = Lookup { set, refs };
835    let words: Vec<&str> = content.split_whitespace().collect();
836    let mut out: Vec<Segment> = Vec::new();
837    let mut text = String::new();
838    // forward modifiers waiting for their emote, plus their literal words to
839    // restore if it never comes.
840    let mut pending = Mods::default();
841    let mut pending_words: Vec<&str> = Vec::new();
842
843    fn push_text(text: &mut String, w: &str) {
844        if !text.is_empty() {
845            text.push(' ');
846        }
847        text.push_str(w);
848    }
849
850    for (i, &word) in words.iter().enumerate() {
851        if let Some(c) = classify(word).filter(|c| c.exact || look.get(word).is_none()) {
852            let ahead = emote_ahead(&words, i + 1, &look);
853            // the stack this word could modify: nothing may sit between them.
854            let back = text.is_empty() && matches!(out.last(), Some(Segment::Stack(_)));
855            // a bare `z!` means "overlay the next emote" — nothing to give a
856            // previous one.
857            let back_ok = back && (!c.mods.fx.is_empty() || c.mods.hue.is_some());
858            let fwd = if c.forward { ahead } else { !back_ok && ahead };
859            if fwd {
860                pending.merge(c.mods);
861                pending_words.push(word);
862                continue;
863            }
864            if back_ok {
865                if let Some(Segment::Stack(s)) = out.last_mut() {
866                    if let Some(l) = s.layers.last_mut() {
867                        l.apply(&c.mods);
868                        continue;
869                    }
870                }
871            }
872            // no emote either side — literal.
873            for w in pending_words.drain(..) {
874                push_text(&mut text, w);
875            }
876            pending = Mods::default();
877            push_text(&mut text, word);
878            continue;
879        }
880
881        let Some(r) = resolve(word, &look) else {
882            for w in pending_words.drain(..) {
883                push_text(&mut text, w);
884            }
885            pending = Mods::default();
886            push_text(&mut text, word);
887            continue;
888        };
889        // a blocked emote is hidden outright: its word, and any modifiers
890        // waiting for it, vanish — the exact emote only, never the name.
891        if crate::block::is_blocked(r.emote) {
892            pending = Mods::default();
893            pending_words.clear();
894            continue;
895        }
896        let mut mods = std::mem::take(&mut pending);
897        pending_words.clear();
898        mods.merge(r.mods);
899        let overlay = r.overlay || mods.zero;
900        let mut layer = Layer::new(r.emote.url.clone());
901        layer.apply(&mods);
902        if overlay && text.is_empty() {
903            if let Some(Segment::Stack(s)) = out.last_mut() {
904                s.layers.push(layer);
905                continue;
906            }
907        }
908        if !text.is_empty() {
909            out.push(Segment::Text(std::mem::take(&mut text)));
910        }
911        out.push(Segment::Stack(Stack::new(&r.emote.name, layer)));
912    }
913    for w in pending_words.drain(..) {
914        push_text(&mut text, w);
915    }
916    if !text.is_empty() {
917        out.push(Segment::Text(text));
918    }
919    out
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925
926    fn e(name: &str) -> Emote {
927        Emote {
928            name: name.into(),
929            url: format!("https://cdn/{name}.webp"),
930            provider: "7tv".into(),
931            id: name.into(),
932            animated: false,
933            zero_width: false,
934            global: false,
935        }
936    }
937
938    fn zw(name: &str) -> Emote {
939        Emote {
940            zero_width: true,
941            ..e(name)
942        }
943    }
944
945    fn native(name: &str, provider: &str) -> Emote {
946        Emote {
947            url: format!("https://native/{name}"),
948            provider: provider.into(),
949            ..e(name)
950        }
951    }
952
953    /// the standard cast: bases, an overlay, a literal `Name0`.
954    fn cast() -> EmoteSet {
955        EmoteSet::from_list([
956            e("KEKW"),
957            e("GAMBA"),
958            e("Kappa"),
959            e("Keepo"),
960            e("lerolero"),
961            zw("notL"),
962        ])
963    }
964
965    /// compact view of a parse: stacks as `base+overlay` with `[codes@hue]`
966    /// per layer, text in quotes, segments space-joined.
967    fn show_with(msg: &str, set: &EmoteSet, refs: &EmoteSet) -> String {
968        segments_with(msg, set, refs)
969            .iter()
970            .map(|s| match s {
971                Segment::Text(t) => format!("'{t}'"),
972                Segment::Stack(st) => st
973                    .layers
974                    .iter()
975                    .map(|l| {
976                        let name = l.url.rsplit('/').next().unwrap();
977                        let name = name.strip_suffix(".webp").unwrap_or(name);
978                        let codes = l.code_line().unwrap_or_default();
979                        match codes.strip_prefix('#') {
980                            Some(c) => format!("{name}[{c}]"),
981                            None => name.to_string(),
982                        }
983                    })
984                    .collect::<Vec<_>>()
985                    .join("+"),
986            })
987            .collect::<Vec<_>>()
988            .join(" ")
989    }
990
991    fn show(msg: &str) -> String {
992        show_with(msg, &cast(), &EmoteSet::new())
993    }
994
995    #[track_caller]
996    fn table(cases: &[(&str, &str)]) {
997        for (input, want) in cases {
998            assert_eq!(show(input), *want, "input: {input}");
999        }
1000    }
1001
1002    #[test]
1003    fn channel_emote_overrides_global_regardless_of_arrival_order() {
1004        // globals landed first
1005        let mut set = EmoteSet::new();
1006        let mut globals = EmoteSet::from_list([e("Kappa")]);
1007        globals.mark_global();
1008        set.merge(globals.clone());
1009        set.merge(EmoteSet::from_list([Emote {
1010            provider: "bttv".into(),
1011            ..e("Kappa")
1012        }]));
1013        assert_eq!(set.get("Kappa").unwrap().provider, "bttv");
1014        // channel set landed first
1015        let mut set = EmoteSet::from_list([Emote {
1016            provider: "bttv".into(),
1017            ..e("Kappa")
1018        }]);
1019        set.merge(globals);
1020        assert_eq!(set.get("Kappa").unwrap().provider, "bttv");
1021        assert_eq!(set.len(), 1);
1022    }
1023
1024    #[test]
1025    fn last_entry_wins_on_collision() {
1026        // server order [bttv, 7tv, ffz]: ffz > 7tv > bttv
1027        let p = |n: &str| Emote {
1028            provider: n.into(),
1029            ..e("Kappa")
1030        };
1031        let set = EmoteSet::from_list([p("bttv"), p("7tv"), p("ffz")]);
1032        assert_eq!(set.get("Kappa").unwrap().provider, "ffz");
1033        assert_eq!(set.len(), 1);
1034        let set = EmoteSet::from_list([p("bttv"), p("7tv")]);
1035        assert_eq!(set.get("Kappa").unwrap().provider, "7tv");
1036    }
1037
1038    #[test]
1039    fn globals_rank_7tv_bttv_ffz_whatever_the_row_order() {
1040        let p = |n: &str| Emote {
1041            provider: n.into(),
1042            ..e("Kappa")
1043        };
1044        for rows in [
1045            vec![p("7tv"), p("bttv"), p("ffz"), p("twitch")],
1046            vec![p("twitch"), p("ffz"), p("bttv"), p("7tv")],
1047            vec![p("ffz"), p("7tv"), p("twitch"), p("bttv")],
1048        ] {
1049            let set = EmoteSet::from_global_list(rows);
1050            let k = set.get("Kappa").unwrap();
1051            assert_eq!(k.provider, "7tv");
1052            assert!(k.global);
1053        }
1054        let set = EmoteSet::from_global_list([p("twitch"), p("ffz")]);
1055        assert_eq!(set.get("Kappa").unwrap().provider, "ffz");
1056    }
1057
1058    #[test]
1059    fn text_and_emotes_coalesce() {
1060        table(&[
1061            (
1062                "lol KEKW that GAMBA moment",
1063                "'lol' KEKW 'that' GAMBA 'moment'",
1064            ),
1065            ("a  b\tc", "'a b c'"),
1066            ("", ""),
1067        ]);
1068    }
1069
1070    #[test]
1071    fn bttv_binds_forward() {
1072        table(&[
1073            ("w!KEKW", "KEKW[w]"),
1074            ("w! KEKW", "KEKW[w]"),
1075            ("w!h!KEKW", "KEKW[xw]"), // sorted = application order
1076            ("x! KEKW", "KEKW[x]"),
1077            ("y! KEKW", "KEKW[y]"),
1078            ("v! KEKW", "KEKW[y]"),
1079            ("l! KEKW", "KEKW[l]"),
1080            ("r! KEKW", "KEKW[r]"),
1081            ("c! KEKW", "KEKW[c]"),
1082            ("p! KEKW", "KEKW[p]"),
1083            ("s! KEKW", "KEKW[s]"),
1084            // canonical side first: c! belongs to Keepo, not Kappa
1085            ("Kappa c! Keepo", "Kappa Keepo[c]"),
1086            // a run of modifiers all land on the emote ahead
1087            ("c! ffzX Kappa", "Kappa[xc]"),
1088            ("w! h! KEKW", "KEKW[xw]"),
1089        ]);
1090    }
1091
1092    #[test]
1093    fn ffz_binds_backward() {
1094        table(&[
1095            ("KEKW ffzX", "KEKW[x]"),
1096            ("KEKW ffzY", "KEKW[y]"),
1097            ("KEKW ffzW", "KEKW[w]"),
1098            ("KEKW ffzWide", "KEKW[w]"),
1099            ("KEKW ffzTall", "KEKW[t]"),
1100            ("KEKW ffzCursed", "KEKW[c]"),
1101            ("KEKW ffzHyper", "KEKW[H]"),
1102            ("KEKW ffzRainbow", "KEKW[R]"),
1103            ("KEKW ffzBounce", "KEKW[b]"),
1104            ("KEKW ffzJam", "KEKW[j]"),
1105            ("KEKW ffzSlide", "KEKW[d]"),
1106            ("KEKW ffzArrive", "KEKW[a]"),
1107            ("KEKW ffzLeave", "KEKW[e]"),
1108            ("KEKW ffzSpin", "KEKW[o]"),
1109            ("KEKW ffzX ffzRainbow", "KEKW[xR]"),
1110            // canonical side first: ffzX belongs to Kappa, not Keepo
1111            ("Kappa ffzX Keepo", "Kappa[x] Keepo"),
1112            // the LAST layer, not the base
1113            ("GAMBA notL ffzX", "GAMBA+notL[x]"),
1114        ]);
1115    }
1116
1117    #[test]
1118    fn fallback_direction_when_canonical_side_has_no_emote() {
1119        table(&[
1120            ("Kappa w!", "Kappa[w]"),   // bttv, nothing ahead → previous
1121            ("ffzX Kappa", "Kappa[x]"), // ffz, nothing behind → next
1122            ("Kappa c!#00f", "Kappa[@240]"),
1123        ]);
1124    }
1125
1126    #[test]
1127    fn orphan_modifier_stays_literal() {
1128        table(&[
1129            ("w! nothing here", "'w! nothing here'"),
1130            ("lol ffzX", "'lol ffzX'"),
1131            ("KEKW lol ffzX", "KEKW 'lol ffzX'"), // adjacency required
1132            ("w! ffzX", "'w! ffzX'"),
1133            ("c!#00f", "'c!#00f'"),
1134            ("z!", "'z!'"),
1135            ("hi w!h!ffzX", "'hi w!h!ffzX'"),
1136        ]);
1137    }
1138
1139    #[test]
1140    fn glued_suffixes() {
1141        table(&[
1142            ("Kappaw!", "Kappa[w]"),
1143            ("KappaffzX", "Kappa[x]"),
1144            ("KappaffzRainbow", "Kappa[R]"),
1145            ("KappaffzLeave", "Kappa[e]"),
1146            ("Kappac!#0f0", "Kappa[@120]"),
1147            ("Kappac!888", "Kappa[@0]"),
1148            ("Kappaw!h!ffzX", "Kappa[xxw]"),
1149            ("Kappaw!c!#00f", "Kappa[w@240]"),
1150            // an overlay with a glued modifier still overlays
1151            ("GAMBA notLw!", "GAMBA+notL[w]"),
1152        ]);
1153    }
1154
1155    #[test]
1156    fn glued_prefixes_still_work() {
1157        table(&[("w!h!KEKW", "KEKW[xw]"), ("z!KEKW", "KEKW")]);
1158    }
1159
1160    #[test]
1161    fn chained_words_follow_the_trailing_bang() {
1162        table(&[
1163            // ends in a letter → ffz-shaped → previous emote
1164            ("Kappa w!h!ffzX", "Kappa[xxw]"),
1165            ("Kappa Keepo w!h!ffzX", "Kappa Keepo[xxw]"),
1166            // ends in `!` → bttv-shaped → next emote
1167            ("Kappa w!h! Keepo", "Kappa Keepo[xw]"),
1168            ("w!c!#00fh! Kappa", "Kappa[xw@240]"),
1169        ]);
1170    }
1171
1172    #[test]
1173    fn hex_colors_tint_and_latest_wins() {
1174        table(&[
1175            ("c!#f00 Kappa", "Kappa[@0]"),
1176            ("c!#0f0 Kappa", "Kappa[@120]"),
1177            ("c!#0000ff Kappa", "Kappa[@240]"),
1178            ("c!00f Kappa", "Kappa[@240]"), // `#` optional
1179            ("c!#f00 c!#00f Kappa", "Kappa[@240]"),
1180        ]);
1181        assert_eq!(hex_hue("ff8700"), Some(32));
1182        assert_eq!(hex_hue("zzz"), None);
1183        assert_eq!(hex_hue("12"), None);
1184    }
1185
1186    #[test]
1187    fn composition_per_hscomposeall() {
1188        // scales multiply and clamp to ±4, rotations add, effects dedupe
1189        table(&[
1190            ("w! w! KEKW", "KEKW[ww]"),
1191            ("KEKW ffzRainbow ffzRainbow", "KEKW[R]"),
1192            ("KEKW ffzHyper ffzHyper s!", "KEKW[Hs]"),
1193            ("w!w!w!w!w!w!KEKW", "KEKW[wwww]"), // capped, keys stay bounded
1194        ]);
1195        let g = |fx: &[Effect]| compose(fx);
1196        assert_eq!((g(&[]).sx, g(&[]).sy, g(&[]).rot), (1, 1, 0));
1197        let wide3 = g(&[Effect::Wide; 3]);
1198        assert_eq!(wide3.sx, 4); // 8 → clamped
1199        assert_eq!(g(&[Effect::FlipX, Effect::FlipX]).sx, 1); // mirrors cancel
1200        assert_eq!(g(&[Effect::FlipX, Effect::Wide]).sx, -2);
1201        assert_eq!(g(&[Effect::Tall]).sy, 2);
1202        assert_eq!(g(&[Effect::RotateL, Effect::RotateL]).rot, 180);
1203        assert_eq!(g(&[Effect::RotateL, Effect::RotateR]).rot, 0);
1204        assert_eq!(g(&[Effect::RotateL]).rot, 270);
1205        assert_eq!(g(&[Effect::RotateR; 4]).rot, 0);
1206    }
1207
1208    #[test]
1209    fn wide_is_the_base_layers_footprint() {
1210        let set = cast();
1211        let wide = |m: &str| match &segments(m, &set)[0] {
1212            Segment::Stack(s) => s.wide(),
1213            _ => panic!("no stack"),
1214        };
1215        assert!(wide("w! KEKW"));
1216        assert!(wide("KEKW ffzWide"));
1217        assert!(!wide("KEKW ffzTall"));
1218        assert!(wide("w! w! w! KEKW")); // clamped, still wide
1219        assert!(wide("w! h! KEKW")); // mirrored 2x is still wide
1220        assert!(!wide("GAMBA notL ffzW")); // overlay resizes onto the base
1221        assert!(!wide("KEKW"));
1222    }
1223
1224    #[test]
1225    fn zero_width_overlays_stack() {
1226        table(&[
1227            ("GAMBA notL", "GAMBA+notL"),
1228            ("GAMBA notL notL", "GAMBA+notL+notL"),
1229            ("notL", "notL"), // nothing to overlay → base
1230            ("GAMBA lol notL", "GAMBA 'lol' notL"),
1231            ("GAMBA w! notL", "GAMBA+notL[w]"),
1232            ("GAMBA notL w!", "GAMBA+notL[w]"),
1233        ]);
1234    }
1235
1236    #[test]
1237    fn z_forces_overlay() {
1238        table(&[
1239            ("GAMBA z!KEKW", "GAMBA+KEKW"),
1240            ("GAMBA z! KEKW", "GAMBA+KEKW"),
1241            ("GAMBA z!w!KEKW", "GAMBA+KEKW[w]"),
1242            ("GAMBA z!", "GAMBA 'z!'"), // z! only looks forward
1243        ]);
1244    }
1245
1246    #[test]
1247    fn name0_overlay_convention() {
1248        table(&[
1249            ("GAMBA lerolero0", "GAMBA+lerolero"),
1250            ("lerolero0", "lerolero"), // no previous stack → base
1251        ]);
1252        // an emote literally named lerolero0 is its own emote
1253        let set = EmoteSet::from_list([e("GAMBA"), e("lerolero"), e("lerolero0")]);
1254        assert_eq!(
1255            show_with("GAMBA lerolero0", &set, &EmoteSet::new()),
1256            "GAMBA lerolero0"
1257        );
1258        // not an emote at all → text
1259        assert_eq!(show("nope0"), "'nope0'");
1260        assert_eq!(show("0"), "'0'");
1261    }
1262
1263    #[test]
1264    fn exact_emote_name_beats_chain_parse() {
1265        let set = EmoteSet::from_list([e("h!i"), e("w!h!")]);
1266        assert_eq!(show_with("h!i", &set, &EmoteSet::new()), "h!i");
1267        // an emote named like a chain wins over parsing it as modifiers
1268        assert_eq!(show_with("w!h!", &set, &EmoteSet::new()), "w!h!");
1269    }
1270
1271    #[test]
1272    fn refs_outrank_the_set_native_included() {
1273        let set = EmoteSet::from_list([e("Kappa")]);
1274        let heat = Emote {
1275            url: "https://hs/Kappa".into(),
1276            provider: "heatsync".into(),
1277            ..e("Kappa")
1278        };
1279        // inventory ref beats the channel emote of the same name
1280        let refs = EmoteSet::from_list([heat]);
1281        assert_eq!(show_with("Kappa", &set, &refs), "Kappa");
1282        match &segments_with("Kappa", &set, &refs)[0] {
1283            Segment::Stack(s) => assert_eq!(s.layers[0].url, "https://hs/Kappa"),
1284            _ => panic!(),
1285        }
1286        // a native twitch token beats the same-name set emote
1287        let refs = EmoteSet::from_list([native("Kappa", "twitch")]);
1288        match &segments_with("Kappa", &set, &refs)[0] {
1289            Segment::Stack(s) => assert_eq!(s.layers[0].url, "https://native/Kappa"),
1290            _ => panic!(),
1291        }
1292        // and resolves a name the set doesn't have, with modifiers
1293        let refs = EmoteSet::from_list([native("SubEmote", "twitch")]);
1294        match &segments_with("w! SubEmote ffzX", &set, &refs)[0] {
1295            Segment::Stack(s) => {
1296                assert_eq!(s.layers[0].url, "https://native/SubEmote");
1297                assert_eq!(s.layers[0].fx, vec![Effect::FlipX, Effect::Wide]);
1298            }
1299            _ => panic!(),
1300        }
1301    }
1302
1303    #[test]
1304    fn keys_are_unique_per_effect_and_roundtrip() {
1305        let set = cast();
1306        let mut keys = std::collections::HashSet::new();
1307        for (name, _) in TOKENS.iter().filter(|(k, _)| *k != "z!") {
1308            let msg = if name.ends_with('!') {
1309                format!("{name} KEKW")
1310            } else {
1311                format!("KEKW {name}")
1312            };
1313            match &segments(&msg, &set)[0] {
1314                Segment::Stack(s) => keys.insert(s.key()),
1315                _ => panic!("{msg}"),
1316            };
1317        }
1318        // 24 words alias into 17 effects (x!/h!/ffzX, y!/v!/ffzY, ffzW/ffzWide/w!)
1319        assert_eq!(keys.len(), 17);
1320        assert_eq!(CODES.len(), 17);
1321        let codes: std::collections::HashSet<_> = CODES.iter().map(|c| c.1).collect();
1322        assert_eq!(codes.len(), 17, "stack-key codes are unique");
1323    }
1324
1325    #[test]
1326    fn key_roundtrips_through_split_key() {
1327        let set = cast();
1328        let s = match &segments("w!GAMBA notL ffzCursed c!#0f0", &set)[0] {
1329            Segment::Stack(s) => s.clone(),
1330            _ => panic!(),
1331        };
1332        assert_eq!(
1333            s.key(),
1334            "https://cdn/GAMBA.webp\n#w\nhttps://cdn/notL.webp\n#c@120"
1335        );
1336        assert_eq!(split_key(&s.key()), s.layers);
1337        // plain stacks keep the old shape: urls only
1338        let plain = match &segments("GAMBA notL", &set)[0] {
1339            Segment::Stack(s) => s.key(),
1340            _ => panic!(),
1341        };
1342        assert_eq!(plain, "https://cdn/GAMBA.webp\nhttps://cdn/notL.webp");
1343        assert_eq!(
1344            split_key("https://x/a\n#xw@7")[0].fx,
1345            vec![Effect::FlipX, Effect::Wide]
1346        );
1347        assert_eq!(split_key("https://x/a\n#xw@7")[0].hue, Some(7));
1348        // a stray effect line with no layer is ignored, not a panic
1349        assert!(split_key("#xw").is_empty());
1350    }
1351
1352    #[test]
1353    fn twitch_native_positions_name_the_covered_text() {
1354        let u =
1355            |id: &str| format!("https://static-cdn.jtvnw.net/emoticons/v2/{id}/default/dark/2.0");
1356        for (tag, text, want) in [
1357            ("25:0-4", "Kappa hi", vec![("Kappa", "25")]),
1358            ("25:0-4,12-16", "Kappa hi Kappa", vec![("Kappa", "25")]),
1359            (
1360                "25:0-4/1902:6-10",
1361                "Kappa Keepo",
1362                vec![("Kappa", "25"), ("Keepo", "1902")],
1363            ),
1364            (
1365                "emotesv2_ab12:3-5",
1366                "hi LUL",
1367                vec![("LUL", "emotesv2_ab12")],
1368            ),
1369            // positions count code points: each emoji is ONE position
1370            ("25:3-7", "\u{1f600}\u{1f600} Kappa", vec![("Kappa", "25")]),
1371            ("25:0-4/", "Kappa hi", vec![("Kappa", "25")]),
1372            ("", "Kappa", vec![]),
1373        ] {
1374            let got: Vec<(String, String)> = twitch_native(tag, text)
1375                .into_iter()
1376                .map(|e| (e.name, e.url))
1377                .collect();
1378            let want: Vec<(String, String)> =
1379                want.into_iter().map(|(n, i)| (n.into(), u(i))).collect();
1380            assert_eq!(got, want, "{tag} / {text}");
1381        }
1382    }
1383
1384    #[test]
1385    fn twitch_native_rejects_bad_input() {
1386        for (tag, text) in [
1387            ("25:0-9", "Kappa"),     // past the end
1388            ("25:4-0", "Kappa"),     // reversed
1389            ("25:a-b", "Kappa"),     // not numbers
1390            ("25", "Kappa"),         // no positions
1391            ("25?x=1:0-4", "Kappa"), // query-ish id
1392            ("25:0-6", "Kappa hi"),  // crosses a space
1393        ] {
1394            let got = twitch_native(tag, text);
1395            assert!(got.is_empty(), "{tag}: {got:?}");
1396        }
1397    }
1398
1399    #[test]
1400    fn kick_native_tokens_become_names_and_refs() {
1401        let (text, refs) = kick_native("hi [emote:37226:KEKW] there");
1402        assert_eq!(text, "hi KEKW there");
1403        assert_eq!(refs.len(), 1);
1404        assert_eq!(refs[0].name, "KEKW");
1405        assert_eq!(refs[0].url, "https://files.kick.com/emotes/37226/fullsize");
1406        assert_eq!(refs[0].provider, "kick");
1407        // glued neighbors get spaced so each is its own word
1408        assert_eq!(kick_native("a[emote:1:X]b").0, "a X b");
1409        assert_eq!(kick_native("[emote:1:X][emote:2:Y]").0, "X Y");
1410        assert_eq!(kick_native("[emote:1:X][emote:2:Y]").1.len(), 2);
1411        // repeats dedupe to one ref
1412        assert_eq!(kick_native("[emote:1:X] [emote:1:X]").1.len(), 1);
1413        // malformed tokens stay as typed
1414        for bad in [
1415            "[emote:abc:X]",
1416            "[emote::X]",
1417            "[emote:1:]",
1418            "[emote:1:X Y]",
1419            "[emote:1:X",
1420            "[emote:../x:X]",
1421            "[emote:12]",
1422        ] {
1423            let (t, r) = kick_native(bad);
1424            assert_eq!(t, bad, "{bad}");
1425            assert!(r.is_empty(), "{bad}");
1426        }
1427        assert_eq!(kick_native("plain").0, "plain");
1428    }
1429
1430    #[test]
1431    fn msg_ref_guards_the_trust_boundary() {
1432        assert!(msg_ref("KEKW", "https://cdn/k.webp", "7tv", false).is_some());
1433        for (n, u) in [
1434            ("", "https://cdn/k"),
1435            ("a b", "https://cdn/k"),
1436            ("KEKW", "http://cdn/k"),
1437            ("KEKW", "javascript:x"),
1438            ("KEKW", "https://cdn/k k"),
1439            ("KEKW", "https://cdn/k\u{1b}[2J"),
1440        ] {
1441            assert!(msg_ref(n, u, "x", false).is_none(), "{n} {u}");
1442        }
1443        // control chars stripped from the name
1444        assert_eq!(
1445            msg_ref("KE\u{1b}KW", "https://cdn/k", "x", false)
1446                .unwrap()
1447                .name,
1448            "KEKW"
1449        );
1450    }
1451
1452    #[test]
1453    fn native_refs_drive_a_full_parse() {
1454        let set = cast();
1455        let (text, refs) = kick_native("[emote:9:SubOnly] w! KEKW");
1456        let r = ref_set(&refs);
1457        let segs = segments_with(&text, &set, &r);
1458        let bases: Vec<&str> = segs
1459            .iter()
1460            .filter_map(|s| match s {
1461                Segment::Stack(st) => Some(st.base.as_str()),
1462                _ => None,
1463            })
1464            .collect();
1465        assert_eq!(bases, vec!["SubOnly", "KEKW"]);
1466        match &segs[0] {
1467            Segment::Stack(st) => {
1468                assert_eq!(st.layers[0].url, "https://files.kick.com/emotes/9/fullsize")
1469            }
1470            _ => panic!(),
1471        }
1472    }
1473
1474    #[test]
1475    fn parses_real_api_json() {
1476        let json = r#"{"channel":"xqc","emotes":[
1477            {"name":"GAMBA","url":"https://cdn.7tv.app/emote/x/1x.webp","provider":"7tv","id":"x","animated":true}
1478        ],"count":1}"#;
1479        let resp: EmoteSetResponse = serde_json::from_str(json).unwrap();
1480        let set = EmoteSet::from_list(resp.emotes);
1481        assert!(set.get("GAMBA").unwrap().animated);
1482    }
1483
1484    #[test]
1485    fn owner_emotes_are_lenient_per_row_and_https_only() {
1486        let v = serde_json::json!({"emotes": [
1487            {"name": "A", "url": "https://cdn.heatsync.org/a.webp", "provider": "upload", "hash": "h1"},
1488            {"name": "B", "url": "/emotes/b.webp"},
1489            {"name": "C", "url": "http://x/c.webp"},
1490            {"name": "D", "url": "//evil/d.webp"},
1491            {"url": "https://x/e.webp"},
1492            {"name": "F", "url": ""},
1493            "junk"]});
1494        let got = owner_list(&v, "https://heatsync.org");
1495        let names: Vec<_> = got.iter().map(|e| e.name.as_str()).collect();
1496        assert_eq!(names, ["A", "B"]);
1497        assert_eq!(got[0].id, "h1");
1498        assert_eq!(got[1].url, "https://heatsync.org/emotes/b.webp");
1499        assert_eq!(got[1].provider, "upload");
1500        assert!(owner_list(&serde_json::json!({}), "x").is_empty());
1501        assert!(owner_list(&serde_json::json!({"emotes": 5}), "x").is_empty());
1502    }
1503
1504    #[test]
1505    fn owner_emotes_listed_last_win_a_name() {
1506        let chan = vec![e("W")];
1507        let mut mine = e("W");
1508        mine.provider = "upload".into();
1509        mine.url = "https://heatsync.org/w.webp".into();
1510        let set = EmoteSet::from_list(chan.into_iter().chain([mine]));
1511        assert_eq!(set.get("W").unwrap().provider, "upload");
1512    }
1513
1514    #[test]
1515    fn a_blocked_emote_vanishes_but_its_same_named_twin_stays() {
1516        let mut mine = e("BlockTwin");
1517        mine.url = "https://block.test/seg/mine.webp".into();
1518        let mut other = e("BlockTwin");
1519        other.url = "https://block.test/seg/other.webp".into();
1520        let set_a = EmoteSet::from_list([mine.clone()]);
1521        let set_b = EmoteSet::from_list([other.clone()]);
1522        let text = |s: &[Segment]| -> String {
1523            s.iter()
1524                .map(|g| match g {
1525                    Segment::Text(t) => t.clone(),
1526                    Segment::Stack(_) => "<emote>".into(),
1527                })
1528                .collect::<Vec<_>>()
1529                .join("|")
1530        };
1531        assert_eq!(text(&segments("hi BlockTwin yo", &set_a)), "hi|<emote>|yo");
1532        assert!(crate::block::block(&mine));
1533        assert_eq!(
1534            text(&segments("hi BlockTwin yo", &set_a)),
1535            "hi yo",
1536            "hidden"
1537        );
1538        assert_eq!(
1539            text(&segments("hi BlockTwin yo", &set_b)),
1540            "hi|<emote>|yo",
1541            "the other emote with that name is untouched"
1542        );
1543        // its pending modifiers go with it instead of turning into stray text
1544        assert_eq!(text(&segments("w! BlockTwin", &set_a)), "");
1545        // aimable while hidden
1546        assert_eq!(
1547            emotes_in("a BlockTwin BlockTwin", &set_a, EmoteSet::none()).len(),
1548            1
1549        );
1550        crate::block::unblock_key(&crate::block::key(&mine).unwrap());
1551        assert_eq!(text(&segments("BlockTwin", &set_a)), "<emote>");
1552    }
1553}