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