1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum EmojiVariant {
21 Emoji,
23 Text,
25}
26
27impl EmojiVariant {
28 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 pub fn name(self) -> &'static str {
39 match self {
40 EmojiVariant::Emoji => "emoji",
41 EmojiVariant::Text => "text",
42 }
43 }
44
45 fn selector(self) -> &'static str {
47 match self {
48 EmojiVariant::Emoji => VARIANT_EMOJI,
49 EmojiVariant::Text => VARIANT_TEXT,
50 }
51 }
52}
53
54pub fn replace(text: &str) -> String {
60 replace_with_variant(text, None)
61}
62
63pub fn replace_with_variant(text: &str, default_variant: Option<EmojiVariant>) -> String {
67 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 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#[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#[derive(Debug, Clone)]
115pub struct Emoji {
116 name: String,
117 style: StyleType,
118 variant: Option<EmojiVariant>,
119 character: String,
120}
121
122impl Emoji {
123 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 pub fn style(mut self, style: impl Into<StyleType>) -> Self {
138 self.style = style.into();
139 self
140 }
141
142 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 pub fn name(&self) -> &str {
156 &self.name
157 }
158
159 pub fn replace(text: &str) -> String {
161 replace(text)
162 }
163}
164
165impl std::fmt::Display for Emoji {
166 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
179fn is_python_space(c: char) -> bool {
182 c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c)
183}
184
185fn 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
201fn 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 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 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 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 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 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}