Skip to main content

squeeze/
emoji.rs

1use super::{ByteSet, Finder};
2use std::ops::Range;
3use std::sync::OnceLock;
4
5/// UTF-8 encoding of U+FE0E VARIATION SELECTOR-15 (text presentation).
6const VS15_BYTES: [u8; 3] = [0xEF, 0xB8, 0x8E];
7/// UTF-8 encoding of U+FE0F VARIATION SELECTOR-16 (emoji presentation).
8const VS16_BYTES: [u8; 3] = [0xEF, 0xB8, 0x8F];
9/// UTF-8 encoding of U+20E3 COMBINING ENCLOSING KEYCAP.
10const KEYCAP_BYTES: [u8; 3] = [0xE2, 0x83, 0xA3];
11
12/// Codepoints with `Emoji_Presentation=Yes` per UTS #51 (plus all SMP
13/// `Emoji=Yes` pictographs, which are unambiguous, and the commonly
14/// emoji-rendered negative-squared letters 1F170/1F171/1F17E/1F17F).
15/// These match bare, without requiring a variation selector.
16///
17/// Sorted, inclusive, non-overlapping ranges for binary search.
18const EMOJI_PRESENTATION: &[(u32, u32)] = &[
19    (0x231A, 0x231B),
20    (0x23E9, 0x23EC),
21    (0x23F0, 0x23F0),
22    (0x23F3, 0x23F3),
23    (0x25FD, 0x25FE),
24    (0x2614, 0x2615),
25    (0x2648, 0x2653),
26    (0x267F, 0x267F),
27    (0x2693, 0x2693),
28    (0x26A1, 0x26A1),
29    (0x26AA, 0x26AB),
30    (0x26BD, 0x26BE),
31    (0x26C4, 0x26C5),
32    (0x26CE, 0x26CE),
33    (0x26D4, 0x26D4),
34    (0x26EA, 0x26EA),
35    (0x26F2, 0x26F3),
36    (0x26F5, 0x26F5),
37    (0x26FA, 0x26FA),
38    (0x26FD, 0x26FD),
39    (0x2705, 0x2705),
40    (0x270A, 0x270B),
41    (0x2728, 0x2728),
42    (0x274C, 0x274C),
43    (0x274E, 0x274E),
44    (0x2753, 0x2755),
45    (0x2757, 0x2757),
46    (0x2795, 0x2797),
47    (0x27B0, 0x27B0),
48    (0x27BF, 0x27BF),
49    (0x2B1B, 0x2B1C),
50    (0x2B50, 0x2B50),
51    (0x2B55, 0x2B55),
52    (0x1F004, 0x1F004),
53    (0x1F0CF, 0x1F0CF),
54    (0x1F170, 0x1F171),
55    (0x1F17E, 0x1F17F),
56    (0x1F18E, 0x1F18E),
57    (0x1F191, 0x1F19A),
58    (0x1F1E6, 0x1F1FF),
59    (0x1F201, 0x1F202),
60    (0x1F21A, 0x1F21A),
61    (0x1F22F, 0x1F22F),
62    (0x1F232, 0x1F23A),
63    (0x1F250, 0x1F251),
64    (0x1F300, 0x1F321),
65    (0x1F324, 0x1F393),
66    (0x1F396, 0x1F397),
67    (0x1F399, 0x1F39B),
68    (0x1F39E, 0x1F3F0),
69    (0x1F3F3, 0x1F3F5),
70    (0x1F3F7, 0x1F4FD),
71    (0x1F4FF, 0x1F53D),
72    (0x1F549, 0x1F54E),
73    (0x1F550, 0x1F567),
74    (0x1F56F, 0x1F570),
75    (0x1F573, 0x1F57A),
76    (0x1F587, 0x1F587),
77    (0x1F58A, 0x1F58D),
78    (0x1F590, 0x1F590),
79    (0x1F595, 0x1F596),
80    (0x1F5A4, 0x1F5A5),
81    (0x1F5A8, 0x1F5A8),
82    (0x1F5B1, 0x1F5B2),
83    (0x1F5BC, 0x1F5BC),
84    (0x1F5C2, 0x1F5C4),
85    (0x1F5D1, 0x1F5D3),
86    (0x1F5DC, 0x1F5DE),
87    (0x1F5E1, 0x1F5E1),
88    (0x1F5E3, 0x1F5E3),
89    (0x1F5E8, 0x1F5E8),
90    (0x1F5EF, 0x1F5EF),
91    (0x1F5F3, 0x1F5F3),
92    (0x1F5FA, 0x1F64F),
93    (0x1F680, 0x1F6C5),
94    (0x1F6CB, 0x1F6D2),
95    (0x1F6D5, 0x1F6D7),
96    (0x1F6DC, 0x1F6E5),
97    (0x1F6E9, 0x1F6E9),
98    (0x1F6EB, 0x1F6EC),
99    (0x1F6F0, 0x1F6F0),
100    (0x1F6F3, 0x1F6FC),
101    (0x1F7E0, 0x1F7EB),
102    (0x1F7F0, 0x1F7F0),
103    (0x1F90C, 0x1F93A),
104    (0x1F93C, 0x1F945),
105    (0x1F947, 0x1F9FF),
106    (0x1FA70, 0x1FA7C),
107    (0x1FA80, 0x1FA88),
108    (0x1FA90, 0x1FABD),
109    (0x1FABF, 0x1FAC5),
110    (0x1FACE, 0x1FADB),
111    (0x1FAE0, 0x1FAE8),
112    (0x1FAF0, 0x1FAF8),
113];
114
115/// Codepoints that are `Emoji=Yes` but `Emoji_Presentation=No` per UTS #51:
116/// they render as text by default and only count as emoji when immediately
117/// followed by VS16 (U+FE0F). Bare `™`, `©`, `☀`, `✔`, ... are everyday text.
118///
119/// Sorted, inclusive, non-overlapping ranges for binary search.
120const TEXT_DEFAULT_EMOJI: &[(u32, u32)] = &[
121    (0x00A9, 0x00A9),
122    (0x00AE, 0x00AE),
123    (0x203C, 0x203C),
124    (0x2049, 0x2049),
125    (0x2122, 0x2122),
126    (0x2139, 0x2139),
127    (0x2194, 0x2199),
128    (0x21A9, 0x21AA),
129    (0x2328, 0x2328),
130    (0x23CF, 0x23CF),
131    (0x23ED, 0x23EF),
132    (0x23F1, 0x23F2),
133    (0x23F8, 0x23FA),
134    (0x24C2, 0x24C2),
135    (0x25AA, 0x25AB),
136    (0x25B6, 0x25B6),
137    (0x25C0, 0x25C0),
138    (0x25FB, 0x25FC),
139    (0x2600, 0x2604),
140    (0x260E, 0x260E),
141    (0x2611, 0x2611),
142    (0x2618, 0x2618),
143    (0x261D, 0x261D),
144    (0x2620, 0x2620),
145    (0x2622, 0x2623),
146    (0x2626, 0x2626),
147    (0x262A, 0x262A),
148    (0x262E, 0x262F),
149    (0x2638, 0x263A),
150    (0x2640, 0x2640),
151    (0x2642, 0x2642),
152    (0x265F, 0x2660),
153    (0x2663, 0x2663),
154    (0x2665, 0x2666),
155    (0x2668, 0x2668),
156    (0x267B, 0x267B),
157    (0x267E, 0x267E),
158    (0x2692, 0x2692),
159    (0x2694, 0x2697),
160    (0x2699, 0x2699),
161    (0x269B, 0x269C),
162    (0x26A0, 0x26A0),
163    (0x26A7, 0x26A7),
164    (0x26B0, 0x26B1),
165    (0x26C8, 0x26C8),
166    (0x26CF, 0x26CF),
167    (0x26D1, 0x26D1),
168    (0x26D3, 0x26D3),
169    (0x26E9, 0x26E9),
170    (0x26F0, 0x26F1),
171    (0x26F4, 0x26F4),
172    (0x26F7, 0x26F9),
173    (0x2702, 0x2702),
174    (0x2708, 0x2709),
175    (0x270C, 0x270D),
176    (0x270F, 0x270F),
177    (0x2712, 0x2712),
178    (0x2714, 0x2714),
179    (0x2716, 0x2716),
180    (0x271D, 0x271D),
181    (0x2721, 0x2721),
182    (0x2733, 0x2734),
183    (0x2744, 0x2744),
184    (0x2747, 0x2747),
185    (0x2763, 0x2764),
186    (0x27A1, 0x27A1),
187    (0x2934, 0x2935),
188    (0x2B05, 0x2B07),
189    (0x3030, 0x3030),
190    (0x303D, 0x303D),
191    (0x3297, 0x3297),
192    (0x3299, 0x3299),
193];
194
195#[derive(Default)]
196pub struct Emoji {}
197
198impl Emoji {
199    fn in_table(table: &[(u32, u32)], cp: u32) -> bool {
200        table
201            .binary_search_by(|&(lo, hi)| {
202                if hi < cp {
203                    std::cmp::Ordering::Less
204                } else if lo > cp {
205                    std::cmp::Ordering::Greater
206                } else {
207                    std::cmp::Ordering::Equal
208                }
209            })
210            .is_ok()
211    }
212
213    /// Matches bare (emoji presentation by default).
214    fn is_emoji_presentation(c: char) -> bool {
215        Self::in_table(EMOJI_PRESENTATION, c as u32)
216    }
217
218    /// Matches only when immediately followed by VS16 (U+FE0F).
219    fn is_text_default_emoji(c: char) -> bool {
220        Self::in_table(TEXT_DEFAULT_EMOJI, c as u32)
221    }
222
223    /// A char that may continue a ZWJ chain.
224    fn is_pictographic(c: char) -> bool {
225        Self::is_emoji_presentation(c) || Self::is_text_default_emoji(c)
226    }
227
228    /// Keycap bases match only as part of a keycap sequence, never bare.
229    fn is_keycap_base(byte: u8) -> bool {
230        matches!(byte, b'0'..=b'9' | b'#' | b'*')
231    }
232
233    fn is_regional_indicator(c: char) -> bool {
234        (0x1F1E6..=0x1F1FF).contains(&(c as u32))
235    }
236
237    fn is_skin_tone(c: char) -> bool {
238        (0x1F3FB..=0x1F3FF).contains(&(c as u32))
239    }
240
241    fn is_tag_char(c: char) -> bool {
242        (0xE0020..=0xE007E).contains(&(c as u32))
243    }
244
245    fn is_tag_cancel(c: char) -> bool {
246        c == '\u{E007F}'
247    }
248
249    /// Decode the single char whose UTF-8 sequence starts at `pos`.
250    ///
251    /// O(1): validates at most 4 bytes, never the rest of the input. Returns
252    /// `None` at end of input, on a continuation/invalid lead byte, or on a
253    /// truncated/overlong sequence, so `try_at` stays safe on arbitrary bytes.
254    fn decode_char(input: &[u8], pos: usize) -> Option<(char, usize)> {
255        let lead = *input.get(pos)?;
256        let len = match lead {
257            0x00..=0x7F => 1,
258            0xC2..=0xDF => 2,
259            0xE0..=0xEF => 3,
260            0xF0..=0xF4 => 4,
261            _ => return None,
262        };
263        let bytes = input.get(pos..pos.checked_add(len)?)?;
264        let c = std::str::from_utf8(bytes).ok()?.chars().next()?;
265        Some((c, len))
266    }
267
268    /// Whether the 3 bytes at `pos` are exactly `pat` (O(1) byte compare).
269    fn bytes_at(input: &[u8], pos: usize, pat: &[u8; 3]) -> bool {
270        pos.checked_add(3)
271            .and_then(|end| input.get(pos..end))
272            .is_some_and(|window| window == pat)
273    }
274
275    /// Match a keycap sequence at `pos`: base + optional U+FE0F + U+20E3.
276    /// Pure byte comparisons so digit-heavy lines bail out in O(1).
277    fn keycap_len(input: &[u8], pos: usize) -> Option<usize> {
278        if Self::bytes_at(input, pos + 1, &KEYCAP_BYTES) {
279            return Some(1 + KEYCAP_BYTES.len());
280        }
281        if Self::bytes_at(input, pos + 1, &VS16_BYTES)
282            && Self::bytes_at(input, pos + 1 + VS16_BYTES.len(), &KEYCAP_BYTES)
283        {
284            return Some(1 + VS16_BYTES.len() + KEYCAP_BYTES.len());
285        }
286        None
287    }
288
289    /// Length of the emoji (sequence) starting exactly at `pos`, if any.
290    ///
291    /// This is the single source of truth used by both `find` and `try_at`,
292    /// which keeps the two dispatch paths in exact parity. It only ever looks
293    /// forward from `pos` and decodes incrementally, so the cost is O(1) for
294    /// a non-match and O(sequence length) for a match.
295    /// Whether some emoji sequence starts with the bytes `lead`, `second`:
296    /// a table of the first two bytes of every code point in the emoji
297    /// ranges, so CJK text and accented letters are rejected without
298    /// decoding.
299    fn prefix_ok(lead: u8, second: u8) -> bool {
300        static TABLE: OnceLock<[ByteSet; 256]> = OnceLock::new();
301        let table = TABLE.get_or_init(|| {
302            let mut table = [ByteSet::EMPTY; 256];
303            for &(lo, hi) in EMOJI_PRESENTATION.iter().chain(TEXT_DEFAULT_EMOJI) {
304                // Every (lead, second) pair of the range: walk it in steps
305                // of 64 code points, the span of one second byte.
306                let mut cp = lo;
307                while cp <= hi {
308                    if let Some(c) = char::from_u32(cp) {
309                        let mut buf = [0u8; 4];
310                        let bytes = c.encode_utf8(&mut buf).as_bytes();
311                        if bytes.len() >= 2 {
312                            table[bytes[0] as usize] = table[bytes[0] as usize].with(bytes[1]);
313                        }
314                    }
315                    // Two-byte sequences change their second byte at every
316                    // code point.
317                    cp = if cp < 0x800 { cp + 1 } else { (cp | 63) + 1 };
318                }
319            }
320            table
321        });
322        table[lead as usize].contains(second)
323    }
324
325    fn match_len_at(input: &[u8], pos: usize) -> Option<usize> {
326        let lead = *input.get(pos)?;
327
328        if lead.is_ascii() {
329            if !Self::is_keycap_base(lead) {
330                return None;
331            }
332            return Self::keycap_len(input, pos);
333        }
334        if !input
335            .get(pos + 1)
336            .is_some_and(|&second| Self::prefix_ok(lead, second))
337        {
338            return None;
339        }
340
341        let (first, first_len) = Self::decode_char(input, pos)?;
342        let after = pos + first_len;
343
344        if Self::is_emoji_presentation(first) {
345            // VS15 explicitly demotes the char to text presentation: no
346            // match, and the selector is skipped along with its base.
347            if Self::bytes_at(input, after, &VS15_BYTES) {
348                return None;
349            }
350            return Some(Self::consume_sequence(input, pos, first, first_len));
351        }
352
353        if Self::is_text_default_emoji(first) && Self::bytes_at(input, after, &VS16_BYTES) {
354            return Some(Self::consume_sequence(input, pos, first, first_len));
355        }
356
357        None
358    }
359
360    /// Extend a confirmed emoji at `pos` over its full sequence (RI pairs,
361    /// tag sequences, VS16, skin tones, ZWJ chains). Returns the total length.
362    fn consume_sequence(input: &[u8], pos: usize, first: char, first_len: usize) -> usize {
363        let mut end = pos + first_len;
364
365        if Self::is_regional_indicator(first) {
366            if let Some((c, len)) = Self::decode_char(input, end)
367                && Self::is_regional_indicator(c)
368            {
369                end += len;
370            }
371            return end - pos;
372        }
373
374        if first == '\u{1F3F4}' {
375            let mut cursor = end;
376            let mut has_tags = false;
377            loop {
378                match Self::decode_char(input, cursor) {
379                    Some((c, len)) if Self::is_tag_char(c) => {
380                        has_tags = true;
381                        cursor += len;
382                    }
383                    Some((c, len)) if Self::is_tag_cancel(c) => {
384                        has_tags = true;
385                        cursor += len;
386                        break;
387                    }
388                    _ => break,
389                }
390            }
391            if has_tags {
392                return cursor - pos;
393            }
394        }
395
396        loop {
397            if let Some((c, len)) = Self::decode_char(input, end)
398                && c == '\u{FE0F}'
399            {
400                end += len;
401            }
402
403            if let Some((c, len)) = Self::decode_char(input, end)
404                && Self::is_skin_tone(c)
405            {
406                end += len;
407            }
408
409            if let Some((c, zwj_len)) = Self::decode_char(input, end)
410                && c == '\u{200D}'
411                && let Some((next, next_len)) = Self::decode_char(input, end + zwj_len)
412                && Self::is_pictographic(next)
413            {
414                end += zwj_len + next_len;
415                continue;
416            }
417
418            break;
419        }
420
421        end - pos
422    }
423}
424
425impl Finder for Emoji {
426    fn line_agnostic(&self) -> bool {
427        // Matches never contain a line terminator and `\n`/`\r` end every
428        // walk exactly like the end of the input does.
429        true
430    }
431
432    fn id(&self) -> &'static str {
433        "emoji"
434    }
435
436    fn dispatchable(&self) -> bool {
437        true
438    }
439
440    fn could_start_at(&self, byte: u8) -> bool {
441        // 0xC2: © ®; 0xE2: U+2000-2FFF symbols and U+20E3-bearing keycaps;
442        // 0xE3: U+3030/303D/3297/3299; 0xF0: all SMP pictographs;
443        // ASCII digits/#/*: keycap sequence bases.
444        matches!(byte, 0xC2 | 0xE2 | 0xE3 | 0xF0) || Self::is_keycap_base(byte)
445    }
446
447    fn could_continue_with(&self, cur: u8, next: u8) -> bool {
448        if Self::is_keycap_base(cur) {
449            next == VS16_BYTES[0] || next == KEYCAP_BYTES[0]
450        } else {
451            // A multi-byte lead byte is always followed by a continuation byte.
452            next & 0xC0 == 0x80
453        }
454    }
455
456    fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
457        let len = Self::match_len_at(input, pos)?;
458        Some(pos..pos + len)
459    }
460
461    fn find(&self, s: &str) -> Option<Range<usize>> {
462        let input = s.as_bytes();
463        for (byte_pos, _) in s.char_indices() {
464            if let Some(len) = Self::match_len_at(input, byte_pos) {
465                return Some(byte_pos..byte_pos + len);
466            }
467        }
468        None
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn id_should_return_emoji() {
478        let finder = Emoji::default();
479        assert_eq!("emoji", finder.id());
480    }
481
482    #[test]
483    fn tables_are_sorted_and_disjoint() {
484        for table in [EMOJI_PRESENTATION, TEXT_DEFAULT_EMOJI] {
485            let mut prev_hi: Option<u32> = None;
486            for &(lo, hi) in table {
487                assert!(lo <= hi, "range {lo:#X}..={hi:#X} is reversed");
488                if let Some(p) = prev_hi {
489                    assert!(p < lo, "range starting at {lo:#X} overlaps or is unsorted");
490                }
491                prev_hi = Some(hi);
492            }
493        }
494        // The two tiers must not overlap each other.
495        for &(lo, hi) in TEXT_DEFAULT_EMOJI {
496            for cp in lo..=hi {
497                assert!(
498                    !Emoji::in_table(EMOJI_PRESENTATION, cp),
499                    "{cp:#X} is in both tiers"
500                );
501            }
502        }
503    }
504
505    #[test]
506    fn in_table_hits_boundaries_and_rejects_gaps() {
507        for cp in [0x231A, 0x231B, 0x2B55, 0x1F004, 0x1F600, 0x1FAF8] {
508            assert!(Emoji::in_table(EMOJI_PRESENTATION, cp), "{cp:#X}");
509        }
510        for cp in [0x2605, 0x2713, 0x2717, 0x1F000, 0x1F031, 0x1FAF9, 0x0041] {
511            assert!(!Emoji::in_table(EMOJI_PRESENTATION, cp), "{cp:#X}");
512            assert!(!Emoji::in_table(TEXT_DEFAULT_EMOJI, cp), "{cp:#X}");
513        }
514        for cp in [0x00A9, 0x00AE, 0x2122, 0x2600, 0x2764, 0x3299] {
515            assert!(Emoji::in_table(TEXT_DEFAULT_EMOJI, cp), "{cp:#X}");
516        }
517    }
518
519    #[test]
520    fn find_should_extract_simple_emoji() {
521        let finder = Emoji::default();
522        let input = "hello 😀 world";
523        let range = finder.find(input).unwrap();
524        assert_eq!("😀", &input[range]);
525    }
526
527    #[test]
528    fn find_should_extract_emoji_at_start() {
529        let finder = Emoji::default();
530        let input = "🎉 party";
531        let range = finder.find(input).unwrap();
532        assert_eq!("🎉", &input[range]);
533    }
534
535    #[test]
536    fn find_should_extract_emoji_at_end() {
537        let finder = Emoji::default();
538        let input = "done ✅";
539        let range = finder.find(input).unwrap();
540        assert_eq!("✅", &input[range]);
541    }
542
543    #[test]
544    fn find_should_extract_emoji_with_skin_tone() {
545        let finder = Emoji::default();
546        let input = "wave 👋🏽 here";
547        let range = finder.find(input).unwrap();
548        assert_eq!("👋🏽", &input[range]);
549    }
550
551    #[test]
552    fn find_should_extract_zwj_sequence() {
553        let finder = Emoji::default();
554        let input = "family 👨\u{200D}👩\u{200D}👧 end";
555        let range = finder.find(input).unwrap();
556        assert_eq!("👨\u{200D}👩\u{200D}👧", &input[range]);
557    }
558
559    #[test]
560    fn find_should_extract_flag() {
561        let finder = Emoji::default();
562        let input = "flag 🇺🇸 end";
563        let range = finder.find(input).unwrap();
564        assert_eq!("🇺🇸", &input[range]);
565    }
566
567    #[test]
568    fn find_should_extract_emoji_with_variation_selector() {
569        let finder = Emoji::default();
570        let input = "heart ❤\u{FE0F} end";
571        let range = finder.find(input).unwrap();
572        assert_eq!("❤\u{FE0F}", &input[range]);
573    }
574
575    #[test]
576    fn find_should_extract_tag_sequence() {
577        let finder = Emoji::default();
578        let input = "flag 🏴\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F} end";
579        let range = finder.find(input).unwrap();
580        assert_eq!(
581            "🏴\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}",
582            &input[range]
583        );
584    }
585
586    #[test]
587    fn find_should_extract_zwj_with_skin_tone() {
588        let finder = Emoji::default();
589        let input = "👩🏽\u{200D}🔬 scientist";
590        let range = finder.find(input).unwrap();
591        assert_eq!("👩🏽\u{200D}🔬", &input[range]);
592    }
593
594    #[test]
595    fn find_should_extract_zwj_with_variation_selector() {
596        let finder = Emoji::default();
597        let input = "❤\u{FE0F}\u{200D}🔥";
598        let range = finder.find(input).unwrap();
599        assert_eq!("❤\u{FE0F}\u{200D}🔥", &input[range]);
600    }
601
602    #[test]
603    fn find_should_extract_multiple_emojis_iteratively() {
604        let finder = Emoji::default();
605        let input = "😀🎉✅";
606
607        let mut results = Vec::new();
608        let mut idx = 0;
609        while idx < input.len() {
610            if let Some(range) = finder.find(&input[idx..]) {
611                results.push(&input[idx + range.start..idx + range.end]);
612                idx += range.end;
613            } else {
614                break;
615            }
616        }
617
618        assert_eq!(vec!["😀", "🎉", "✅"], results);
619    }
620
621    #[test]
622    fn find_should_extract_adjacent_flags() {
623        let finder = Emoji::default();
624        let input = "🇺🇸🇬🇧";
625
626        let mut results = Vec::new();
627        let mut idx = 0;
628        while idx < input.len() {
629            if let Some(range) = finder.find(&input[idx..]) {
630                results.push(&input[idx + range.start..idx + range.end]);
631                idx += range.end;
632            } else {
633                break;
634            }
635        }
636
637        assert_eq!(vec!["🇺🇸", "🇬🇧"], results);
638    }
639
640    #[test]
641    fn find_should_handle_empty_input() {
642        let finder = Emoji::default();
643        assert!(finder.find("").is_none());
644    }
645
646    #[test]
647    fn find_should_reject_plain_ascii() {
648        let finder = Emoji::default();
649        assert!(finder.find("hello world 123").is_none());
650    }
651
652    #[test]
653    fn find_should_not_start_with_zwj() {
654        let finder = Emoji::default();
655        assert!(finder.find("\u{200D}hello").is_none());
656    }
657
658    #[test]
659    fn find_should_extract_misc_symbol_with_vs16_only() {
660        // Pin flipped with the UTS #51 tables: U+2600 is text-default, so
661        // bare `☀` is plain text and only `☀️` (with VS16) is an emoji.
662        let finder = Emoji::default();
663        assert!(finder.find("sun ☀ here").is_none());
664        let input = "sun ☀\u{FE0F} here";
665        let range = finder.find(input).unwrap();
666        assert_eq!("☀\u{FE0F}", &input[range]);
667    }
668
669    #[test]
670    fn find_should_extract_dingbat_with_vs16_only() {
671        // Pin flipped: U+2702 is text-default (was matched bare before).
672        let finder = Emoji::default();
673        assert!(finder.find("check ✂ here").is_none());
674        let input = "check ✂\u{FE0F} here";
675        let range = finder.find(input).unwrap();
676        assert_eq!("✂\u{FE0F}", &input[range]);
677    }
678
679    #[test]
680    fn find_should_extract_supplemental_arrow_with_vs16_only() {
681        // Pin flipped: U+27A1 is text-default (was matched bare before).
682        let finder = Emoji::default();
683        assert!(finder.find("go ➡ there").is_none());
684        let input = "go ➡\u{FE0F} there";
685        let range = finder.find(input).unwrap();
686        assert_eq!("➡\u{FE0F}", &input[range]);
687    }
688
689    #[test]
690    fn find_should_extract_emoji_in_brackets() {
691        let finder = Emoji::default();
692        let input = "[😀]";
693        let range = finder.find(input).unwrap();
694        assert_eq!("😀", &input[range]);
695    }
696
697    // --- Regression: try_at with unchecked UTF-8 ---
698
699    #[test]
700    fn try_at_simple_emoji() {
701        let finder = Emoji::default();
702        let input = "😀 rest".as_bytes();
703        let range = finder.try_at(input, 0).unwrap();
704        assert_eq!(range, 0..4);
705    }
706
707    #[test]
708    fn try_at_emoji_after_ascii() {
709        let finder = Emoji::default();
710        let input = "hi 😀 rest";
711        let bytes = input.as_bytes();
712        let range = finder.try_at(bytes, 3).unwrap();
713        assert_eq!(&input[range], "😀");
714    }
715
716    #[test]
717    fn try_at_emoji_with_skin_tone() {
718        let finder = Emoji::default();
719        let input = "👋🏽";
720        let bytes = input.as_bytes();
721        let range = finder.try_at(bytes, 0).unwrap();
722        assert_eq!(&input[range], "👋🏽");
723    }
724
725    #[test]
726    fn try_at_non_emoji_multibyte() {
727        let finder = Emoji::default();
728        let input = "héllo";
729        let bytes = input.as_bytes();
730        assert!(finder.try_at(bytes, 0).is_none());
731    }
732
733    #[test]
734    fn try_at_zwj_sequence_via_dispatch() {
735        let finder = Emoji::default();
736        let input = "👨\u{200D}👩\u{200D}👧";
737        let bytes = input.as_bytes();
738        let range = finder.try_at(bytes, 0).unwrap();
739        assert_eq!(&input[range], "👨\u{200D}👩\u{200D}👧");
740    }
741
742    #[test]
743    fn try_at_flag_sequence() {
744        let finder = Emoji::default();
745        let input = "🇺🇸";
746        let bytes = input.as_bytes();
747        let range = finder.try_at(bytes, 0).unwrap();
748        assert_eq!(&input[range], "🇺🇸");
749    }
750
751    #[test]
752    fn try_at_mid_zwj_matches_and_scanner_suppresses() {
753        // Pin flipped: the old preceding-ZWJ veto made this return None, which
754        // broke find()/try_at parity on dangling ZWJs. try_at only looks
755        // forward; positions inside an already-consumed sequence are skipped
756        // by the Scanner via finder_pos, not by the finder itself.
757        let finder = Emoji::default();
758        let input = "👨\u{200D}👩";
759        let bytes = input.as_bytes();
760        let emoji2_start = "👨\u{200D}".len();
761        assert_eq!(
762            finder.try_at(bytes, emoji2_start),
763            Some(emoji2_start..input.len())
764        );
765    }
766}