Skip to main content

heatsync_core/
emote.rs

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