Skip to main content

rich/
emoji.rs

1//! Emoji shortcode replacement.
2//!
3//! Port of upstream `rich/_emoji_replace.py` + the full `rich/_emoji_codes.py`
4//! table (see `emoji_codes.rs`). [`replace`] turns `:name:` shortcodes into
5//! emoji, with optional `:name-emoji:` / `:name-text:` variant selectors.
6//! Unknown codes are left untouched (matching upstream). [`Emoji`] is
7//! upstream's `rich.emoji.Emoji` renderable.
8
9use crate::console::{Console, ConsoleOptions};
10use crate::protocol::Renderable;
11use crate::segment::Segment;
12use crate::style::StyleType;
13
14const VARIANT_EMOJI: &str = "\u{fe0f}";
15const VARIANT_TEXT: &str = "\u{fe0e}";
16
17/// An emoji presentation variant. Mirrors `rich.emoji.EmojiVariant`
18/// (`"emoji"` or `"text"`).
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum EmojiVariant {
21    /// Emoji presentation: appends U+FE0F.
22    Emoji,
23    /// Text presentation: appends U+FE0E.
24    Text,
25}
26
27impl EmojiVariant {
28    /// Parse upstream's variant name (`"emoji"` or `"text"`).
29    pub fn parse(name: &str) -> Option<EmojiVariant> {
30        match name {
31            "emoji" => Some(EmojiVariant::Emoji),
32            "text" => Some(EmojiVariant::Text),
33            _ => None,
34        }
35    }
36
37    /// The variant's name, as upstream spells it.
38    pub fn name(self) -> &'static str {
39        match self {
40            EmojiVariant::Emoji => "emoji",
41            EmojiVariant::Text => "text",
42        }
43    }
44
45    /// The variation selector this variant appends.
46    fn selector(self) -> &'static str {
47        match self {
48            EmojiVariant::Emoji => VARIANT_EMOJI,
49            EmojiVariant::Text => VARIANT_TEXT,
50        }
51    }
52}
53
54/// Replace `:name:` emoji shortcodes in `text`. Port of `_emoji_replace`.
55///
56/// Mirrors one `re.sub` over `(:(\S*?)(?:(?:\-)(emoji|text))?:)`: every match,
57/// including an unknown code or an empty `::`, is consumed, and scanning
58/// resumes after its closing colon.
59pub fn replace(text: &str) -> String {
60    replace_with_variant(text, None)
61}
62
63/// [`replace`] with upstream's `default_variant`: the selector appended to a
64/// code that names no variant of its own. Port of
65/// `_emoji_replace(text, default_variant=โ€ฆ)`.
66pub fn replace_with_variant(text: &str, default_variant: Option<EmojiVariant>) -> String {
67    // Every code is between colons: text without one comes back as it is,
68    // without the scan (docs/PORTING.md, "Performance notes").
69    if !text.contains(':') {
70        return text.to_string();
71    }
72    let chars: Vec<char> = text.chars().collect();
73    let len = chars.len();
74    let mut out = String::with_capacity(text.len());
75    let mut index = 0;
76
77    while index < len {
78        if chars[index] == ':' {
79            // The lazy `\S*?` stops at the first ':' that follows only
80            // non-whitespace characters.
81            let mut end = index + 1;
82            while end < len && !is_python_space(chars[end]) && chars[end] != ':' {
83                end += 1;
84            }
85            if end < len && chars[end] == ':' {
86                let body: String = chars[index + 1..end].iter().collect();
87                match lookup(&body, default_variant) {
88                    Some(replacement) => out.push_str(&replacement),
89                    None => out.extend(&chars[index..=end]),
90                }
91                index = end + 1;
92                continue;
93            }
94        }
95        out.push(chars[index]);
96        index += 1;
97    }
98    out
99}
100
101/// No emoji by that name. Port of `rich.emoji.NoEmoji`.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct NoEmoji(pub String);
104
105impl std::fmt::Display for NoEmoji {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        f.write_str(&self.0)
108    }
109}
110
111impl std::error::Error for NoEmoji {}
112
113/// A single emoji character, by name. Port of `rich.emoji.Emoji`.
114#[derive(Debug, Clone)]
115pub struct Emoji {
116    name: String,
117    style: StyleType,
118    variant: Option<EmojiVariant>,
119    character: String,
120}
121
122impl Emoji {
123    /// The emoji called `name` (exactly, as upstream's `EMOJI[name]`), or
124    /// [`NoEmoji`].
125    pub fn new(name: &str) -> Result<Emoji, NoEmoji> {
126        let character = emoji_code(name)
127            .ok_or_else(|| NoEmoji(format!("No emoji called {}", crate::pretty::str_repr(name))))?;
128        Ok(Emoji {
129            name: name.to_string(),
130            style: StyleType::from("none"),
131            variant: None,
132            character: character.to_string(),
133        })
134    }
135
136    /// The style of the emoji (upstream `style`, default `"none"`).
137    pub fn style(mut self, style: impl Into<StyleType>) -> Self {
138        self.style = style.into();
139        self
140    }
141
142    /// Append the variant's selector (upstream `variant`).
143    pub fn variant(mut self, variant: EmojiVariant) -> Self {
144        if self.variant.is_none() {
145            self.character.push_str(variant.selector());
146        } else {
147            let base = emoji_code(&self.name).unwrap_or_default();
148            self.character = format!("{base}{}", variant.selector());
149        }
150        self.variant = Some(variant);
151        self
152    }
153
154    /// The emoji's name.
155    pub fn name(&self) -> &str {
156        &self.name
157    }
158
159    /// Replace `:name:` codes in `text`. Port of `Emoji.replace`.
160    pub fn replace(text: &str) -> String {
161        replace(text)
162    }
163}
164
165impl std::fmt::Display for Emoji {
166    /// The emoji character (upstream `__str__`).
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        f.write_str(&self.character)
169    }
170}
171
172impl Renderable for Emoji {
173    fn rich_render(&self, console: &Console, _options: &ConsoleOptions) -> Vec<Segment> {
174        let style = console.get_style(&self.style).unwrap_or_default();
175        vec![Segment::new(self.character.clone(), Some(style))]
176    }
177}
178
179/// Python's `str.isspace()`, which `\s` follows for `str` patterns. Unlike
180/// `char::is_whitespace` it includes the separators U+001C..=U+001F.
181fn is_python_space(c: char) -> bool {
182    c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c)
183}
184
185/// Resolve a shortcode body (with optional variant suffix) to its replacement.
186///
187/// The suffix is matched case-sensitively, as upstream's `(emoji|text)` group
188/// is; only the name is lowercased (`get_emoji(emoji_name.lower())`).
189fn lookup(body: &str, default_variant: Option<EmojiVariant>) -> Option<String> {
190    let default = default_variant.map_or("", EmojiVariant::selector);
191    let (name, variant) = if let Some(name) = body.strip_suffix("-emoji") {
192        (name, VARIANT_EMOJI)
193    } else if let Some(name) = body.strip_suffix("-text") {
194        (name, VARIANT_TEXT)
195    } else {
196        (body, default)
197    };
198    emoji_code(&name.to_lowercase()).map(|code| format!("{code}{variant}"))
199}
200
201/// Resolve an emoji name to its glyph(s) from the full vendored table.
202fn emoji_code(name: &str) -> Option<&'static str> {
203    crate::emoji_codes::emoji_code(name)
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn replaces_known_codes() {
212        // Captured from real rich 15.0.0 `_emoji_replace`.
213        assert_eq!(replace(":rocket:"), "\u{1f680}");
214        assert_eq!(replace("hello :thumbs_up: world"), "hello \u{1f44d} world");
215        assert_eq!(replace(":rocket: to :moon:"), "\u{1f680} to \u{1f314}");
216    }
217
218    #[test]
219    fn leaves_unknown_and_bare_colons() {
220        assert_eq!(replace(":not_a_real_emoji:"), ":not_a_real_emoji:");
221        assert_eq!(replace("no emoji"), "no emoji");
222        assert_eq!(replace("ratio 3:4 done"), "ratio 3:4 done");
223    }
224
225    #[test]
226    fn emoji_renderable_matches_upstream() {
227        // Captured from rich 15.0.0: `str(Emoji("rocket", variant="text"))`,
228        // `NoEmoji("No emoji called 'nope'")`, and `Emoji` printed in red.
229        let rocket = Emoji::new("rocket").unwrap();
230        assert_eq!(rocket.to_string(), "\u{1f680}");
231        assert_eq!(
232            rocket.clone().variant(EmojiVariant::Text).to_string(),
233            "\u{1f680}\u{fe0e}"
234        );
235        assert_eq!(
236            Emoji::new("nope").unwrap_err().to_string(),
237            "No emoji called 'nope'"
238        );
239        assert!(Emoji::new("Rocket").is_err());
240        let console = crate::Console::builder()
241            .force_terminal(true)
242            .color_system(Some(crate::ColorSystem::Truecolor))
243            .width(10)
244            .build();
245        // Upstream prints no newline after it; `print` here always ends the
246        // line (DIVERGENCES ยง29), so compare the rendered segment.
247        let out = console.render_to_string(&rocket.style("red"));
248        assert_eq!(out, "\x1b[31m\u{1f680}\x1b[0m");
249    }
250
251    #[test]
252    fn variant_selectors() {
253        assert_eq!(replace(":rocket-emoji:"), "\u{1f680}\u{fe0f}");
254        assert_eq!(replace(":rocket-text:"), "\u{1f680}\u{fe0e}");
255    }
256
257    #[test]
258    fn default_variant_and_case_sensitive_suffix() {
259        // Captured from real rich 15.0.0 `_emoji_replace`.
260        assert_eq!(replace(":rocket-EMOJI:"), ":rocket-EMOJI:");
261        assert_eq!(replace(":ROCKET-text:"), "\u{1f680}\u{fe0e}");
262        assert_eq!(
263            replace_with_variant(":rocket: :rocket-text:", Some(EmojiVariant::Emoji)),
264            "\u{1f680}\u{fe0f} \u{1f680}\u{fe0e}"
265        );
266        assert_eq!(
267            replace_with_variant(":rocket:", Some(EmojiVariant::Text)),
268            "\u{1f680}\u{fe0e}"
269        );
270    }
271
272    #[test]
273    fn scanning_resumes_after_every_match() {
274        // Captured from real rich 15.0.0 `_emoji_replace` (#448).
275        assert_eq!(replace(" -:็•Œ[ba:b: x"), " -:็•Œ[ba:b: x");
276        assert_eq!(replace("::rocket:"), "::rocket:");
277        assert_eq!(replace(":x:rocket:"), "\u{274c}rocket:");
278        assert_eq!(replace(":rocket:rocket:"), "\u{1f680}rocket:");
279        assert_eq!(replace(":ROCKET:"), "\u{1f680}");
280        assert_eq!(replace("a:\u{1c}rocket:"), "a:\u{1c}rocket:");
281        assert_eq!(replace(":\u{212a}rocket:"), ":\u{212a}rocket:");
282    }
283}