Skip to main content

html_generator/
emojis.rs

1// Copyright © 2025 HTML Generator. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Emoji Sequences Loader
5//!
6//! Emoji data copyright (c) 2024 Unicode, Inc.
7//! License: <http://www.unicode.org/copyright.html>
8//! For terms of use, see <http://www.unicode.org/terms_of_use.html>
9//!
10//! This module provides functions to load and parse emoji sequences
11//! from a simple text file. Each line in the file typically consists
12//! of three fields separated by semicolons, for example:
13//!
14//! ```text
15//! 2B06 FE0F ; Basic_Emoji ; up
16//! ```
17//!
18//! ### Field Breakdown:
19//! 1. `2B06 FE0F`: The hexadecimal code points for the emoji sequence.
20//! 2. `Basic_Emoji`: A type field (often unused in this context).
21//! 3. `up`: The user-friendly label or description for the emoji sequence.
22//!
23//! ### Notes:
24//! - Lines that start with `#` or are blank are treated as comments.
25//! - Trailing comments in the file are ignored or processed to derive the emoji's descriptive label.
26//!
27//! ### Example Comment Parsing:
28//! ```text
29//! 26A1 ; emoji ; L1 ; none ; a j # V4.0 (⚡) HIGH VOLTAGE SIGN
30//! ```
31//! The descriptive label derived would be: `"high-voltage-sign"`.
32
33use std::collections::HashMap;
34use std::fs;
35use std::path::Path;
36
37/// Emoji data bundled at compile time, ensuring availability regardless
38/// of working directory or deployment environment.
39static BUNDLED_EMOJI_DATA: &str =
40    include_str!("../data/emoji-data.txt");
41
42/// Returns the bundled emoji sequence map.
43///
44/// This uses `include_str!` to embed `data/emoji-data.txt` at compile
45/// time, so the data is always available without relying on the
46/// filesystem at runtime.
47///
48/// # Examples
49///
50/// ```
51/// use html_generator::emojis::bundled_emoji_sequences;
52///
53/// let map = bundled_emoji_sequences();
54/// assert!(!map.is_empty(), "bundled emoji map should ship populated");
55/// ```
56pub fn bundled_emoji_sequences() -> HashMap<String, String> {
57    parse_emoji_sequences(BUNDLED_EMOJI_DATA)
58}
59
60/// Parses emoji sequences and their descriptive labels from a string.
61///
62/// Each line in the input typically consists of three fields separated
63/// by semicolons, for example:
64///
65/// ```text
66/// 26A1 ; emoji ; L1 ; none ; a j # V4.0 (⚡) HIGH VOLTAGE SIGN
67/// ```
68///
69/// The mapping constructed will use the UTF-8 emoji sequence as the key
70/// and a normalized, human-readable label as the value. For instance:
71/// - `"⚡"` → `"high-voltage-sign"`
72///
73/// Lines starting with `#` or empty lines are ignored. Comments after a
74/// `#` are parsed to extract descriptive labels.
75///
76/// # Examples
77///
78/// ```
79/// use html_generator::emojis::parse_emoji_sequences;
80///
81/// let raw = "26A1 ; emoji ; L1 ; none ; a j # V4.0 (⚡) HIGH VOLTAGE SIGN\n";
82/// let map = parse_emoji_sequences(raw);
83/// assert_eq!(map.get("⚡"), Some(&"high-voltage-sign".to_string()));
84/// ```
85pub fn parse_emoji_sequences(
86    contents: &str,
87) -> HashMap<String, String> {
88    let mut map = HashMap::new();
89
90    for raw_line in contents.lines() {
91        let line = raw_line.trim();
92
93        // Skip empty lines or comments
94        if line.is_empty() || line.starts_with('#') {
95            continue;
96        }
97
98        // Separate the data portion from the comment portion (if any)
99        let (data_part, comment_part) = match line.split_once('#') {
100            Some((before, after)) => (before.trim(), after.trim()),
101            None => (line, ""),
102        };
103
104        // Extract the label from the comment portion
105        let raw_label_after_paren =
106            if let Some(close_paren_idx) = comment_part.find(')') {
107                &comment_part[close_paren_idx + 1..]
108            } else {
109                comment_part
110            };
111
112        // Normalize the label
113        let short_label = raw_label_after_paren
114            .trim()
115            .to_lowercase()
116            .split_whitespace()
117            .collect::<Vec<_>>()
118            .join("-");
119
120        // Parse data fields
121        let data_fields: Vec<&str> =
122            data_part.split(';').map(|s| s.trim()).collect();
123        if data_fields.is_empty() {
124            continue;
125        }
126
127        // Extract the hexadecimal code points
128        let hex_seq = data_fields[0];
129
130        // Convert hex code points into a UTF-8 emoji string
131        let emoji_string: String = hex_seq
132            .split_whitespace()
133            .filter_map(|hex| u32::from_str_radix(hex, 16).ok())
134            .flat_map(char::from_u32)
135            .collect();
136
137        if emoji_string.is_empty() {
138            continue; // Skip invalid sequences
139        }
140
141        // Insert the emoji string and its label into the map
142        let _ = map.insert(emoji_string, short_label);
143    }
144
145    map
146}
147
148/// Loads emoji sequences and their descriptive labels from a file.
149///
150/// This is a convenience wrapper around [`parse_emoji_sequences`] for
151/// loading from a filesystem path.
152///
153/// # Arguments
154///
155/// * `filepath` - A path-like reference to the input file.
156///
157/// # Returns
158///
159/// A [`HashMap<String, String>`] mapping emoji strings to labels.
160///
161/// # Errors
162///
163/// Returns an error if the file cannot be read.
164///
165/// # Examples
166///
167/// ```
168/// use html_generator::emojis::load_emoji_sequences;
169/// use std::io::Write;
170///
171/// let mut file = tempfile::NamedTempFile::new().unwrap();
172/// writeln!(file, "26A1 ; emoji ; L1 ; none ; a j # V4.0 (⚡) HIGH VOLTAGE SIGN").unwrap();
173/// let map = load_emoji_sequences(file.path()).unwrap();
174/// assert_eq!(map.get("⚡"), Some(&"high-voltage-sign".to_string()));
175/// ```
176pub fn load_emoji_sequences<P: AsRef<Path>>(
177    filepath: P,
178) -> Result<HashMap<String, String>, std::io::Error> {
179    let contents = fs::read_to_string(filepath)?;
180    Ok(parse_emoji_sequences(&contents))
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::io::Write;
187    use tempfile::NamedTempFile;
188
189    /// Helper function to write test data to a temporary file and return the path.
190    fn create_temp_file(content: &str) -> NamedTempFile {
191        let mut file = NamedTempFile::new()
192            .expect("Failed to create temporary file");
193        file.write_all(content.as_bytes())
194            .expect("Failed to write to temporary file");
195        file
196    }
197
198    #[cfg_attr(
199        miri,
200        ignore = "touches the filesystem; Miri isolation forbids it"
201    )]
202    #[test]
203    fn test_load_emoji_sequences_basic() {
204        let test_data = r#"
205            26A1 ; emoji ; L1 ; none ; a j # V4.0 (⚡) HIGH VOLTAGE SIGN
206            1F600 ; emoji ; L1 ; none ; j     # V6.0 (😀) GRINNING FACE
207        "#;
208
209        let file = create_temp_file(test_data);
210
211        let result = load_emoji_sequences(file.path()).unwrap();
212
213        let mut expected = HashMap::new();
214        let _ = expected
215            .insert("⚡".to_string(), "high-voltage-sign".to_string());
216        let _ = expected
217            .insert("😀".to_string(), "grinning-face".to_string());
218
219        assert_eq!(result, expected);
220    }
221
222    #[cfg_attr(
223        miri,
224        ignore = "touches the filesystem; Miri isolation forbids it"
225    )]
226    #[test]
227    fn test_load_emoji_sequences_empty_file() {
228        let test_data = "";
229
230        let file = create_temp_file(test_data);
231
232        let result = load_emoji_sequences(file.path());
233
234        assert!(result.unwrap().is_empty());
235    }
236
237    #[cfg_attr(
238        miri,
239        ignore = "touches the filesystem; Miri isolation forbids it"
240    )]
241    #[test]
242    fn test_load_emoji_sequences_with_comments_and_blanks() {
243        let test_data = r#"
244    # This is a comment
245
246    1F44D ; emoji ; L1 ; none ; j # V6.0 (👍) THUMBS UP SIGN
247
248    # Another comment here
249
250"#;
251
252        let file = create_temp_file(test_data);
253
254        let result = load_emoji_sequences(file.path());
255
256        let mut expected = HashMap::new();
257        let _ = expected
258            .insert("👍".to_string(), "thumbs-up-sign".to_string());
259
260        assert_eq!(result.unwrap(), expected);
261    }
262
263    #[cfg_attr(
264        miri,
265        ignore = "touches the filesystem; Miri isolation forbids it"
266    )]
267    #[test]
268    fn test_load_emoji_sequences_no_comment_label() {
269        let test_data = r#"
270    1F4AF ; emoji ; L1 ; none ; j # V6.0 (💯) HUNDRED POINTS SYMBOL
271    1F602 ; emoji ; L1 ; none ; j
272"#;
273
274        let file = create_temp_file(test_data);
275
276        let result = load_emoji_sequences(file.path());
277
278        let mut expected = HashMap::new();
279        let _ = expected.insert(
280            "💯".to_string(),
281            "hundred-points-symbol".to_string(),
282        );
283        let _ = expected.insert("😂".to_string(), "".to_string()); // No comment means empty label
284
285        assert_eq!(result.unwrap(), expected);
286    }
287
288    #[cfg_attr(
289        miri,
290        ignore = "touches the filesystem; Miri isolation forbids it"
291    )]
292    #[test]
293    fn test_load_emoji_sequences_invalid_hex_code() {
294        let test_data = r#"
295    26A1 ; emoji ; L1 ; none ; a j # V4.0 (⚡) HIGH VOLTAGE SIGN
296    INVALID_HEX ; emoji ; L1 ; none ; j # Invalid hex code
297"#;
298
299        let file = create_temp_file(test_data);
300
301        let result = load_emoji_sequences(file.path());
302
303        let mut expected = HashMap::new();
304        let _ = expected
305            .insert("⚡".to_string(), "high-voltage-sign".to_string());
306
307        assert_eq!(result.unwrap(), expected);
308    }
309
310    #[cfg_attr(
311        miri,
312        ignore = "touches the filesystem; Miri isolation forbids it"
313    )]
314    #[test]
315    fn test_load_emoji_sequences_multi_codepoint() {
316        let test_data = r#"
317    1F1E6 1F1FA ; emoji ; L1 ; none ; j # V6.0 (🇦🇺) FLAG FOR AUSTRALIA
318"#;
319
320        let file = create_temp_file(test_data);
321
322        let result = load_emoji_sequences(file.path());
323
324        let mut expected = HashMap::new();
325        let _ = expected
326            .insert("🇦🇺".to_string(), "flag-for-australia".to_string());
327
328        assert_eq!(result.unwrap(), expected);
329    }
330
331    #[cfg_attr(
332        miri,
333        ignore = "touches the filesystem; Miri isolation forbids it"
334    )]
335    #[test]
336    fn test_load_emoji_sequences_missing_label() {
337        let test_data = r#"
338    1F44D ; emoji ; L1 ; none ; j # V6.0 (👍) THUMBS UP SIGN
339    1F602 ; emoji ; L1 ; none ; j
340    1F600 ; emoji ; L1 ; none ; j #
341"#;
342
343        let file = create_temp_file(test_data);
344
345        let result = load_emoji_sequences(file.path());
346
347        let mut expected = HashMap::new();
348        let _ = expected
349            .insert("👍".to_string(), "thumbs-up-sign".to_string());
350        let _ = expected.insert("😂".to_string(), "".to_string()); // Missing label
351        let _ = expected.insert("😀".to_string(), "".to_string()); // Empty comment after '#'
352
353        assert_eq!(result.unwrap(), expected);
354    }
355
356    #[cfg_attr(
357        miri,
358        ignore = "touches the filesystem; Miri isolation forbids it"
359    )]
360    #[test]
361    fn test_load_emoji_sequences_handles_empty_and_whitespace() {
362        let test_data = r#"
363
364    1F602 ; emoji ; L1 ; none ; j # V6.0 (😂) FACE WITH TEARS OF JOY
365
366    "#;
367
368        let file = create_temp_file(test_data);
369
370        let result = load_emoji_sequences(file.path());
371
372        let mut expected = HashMap::new();
373        let _ = expected.insert(
374            "😂".to_string(),
375            "face-with-tears-of-joy".to_string(),
376        );
377
378        assert_eq!(result.unwrap(), expected);
379    }
380
381    #[cfg_attr(
382        miri,
383        ignore = "touches the filesystem; Miri isolation forbids it"
384    )]
385    #[test]
386    fn test_load_emoji_sequences_handles_trailing_whitespace() {
387        let test_data = r#"
388    1F602 ; emoji ; L1 ; none ; j # V6.0 (😂) FACE WITH TEARS OF JOY
389    "#;
390
391        let file = create_temp_file(test_data);
392
393        let result = load_emoji_sequences(file.path());
394
395        let mut expected = HashMap::new();
396        let _ = expected.insert(
397            "😂".to_string(),
398            "face-with-tears-of-joy".to_string(),
399        );
400
401        assert_eq!(result.unwrap(), expected);
402    }
403
404    #[cfg_attr(
405        miri,
406        ignore = "touches the filesystem; Miri isolation forbids it"
407    )]
408    #[test]
409    fn test_load_emoji_sequences_skip_invalid_lines() {
410        let test_data = r#"
411    # Comment line
412    ; invalid line ; no hex code ; # Just semicolons
413    1F602 ; emoji ; L1 ; none ; j # V6.0 (😂) FACE WITH TEARS OF JOY
414    "#;
415
416        let file = create_temp_file(test_data);
417        let result = load_emoji_sequences(file.path()).unwrap();
418
419        // Only the valid emoji line should be processed
420        let mut expected = HashMap::new();
421        let _ = expected.insert(
422            "😂".to_string(),
423            "face-with-tears-of-joy".to_string(),
424        );
425        assert_eq!(result, expected);
426    }
427
428    #[cfg_attr(
429        miri,
430        ignore = "touches the filesystem; Miri isolation forbids it"
431    )]
432    #[test]
433    fn test_load_emoji_sequences_split_behavior() {
434        let test_data = r#"
435    26A1;emoji;L1;none;a j# V4.0 (⚡) HIGH VOLTAGE SIGN
436    1F602 ; emoji ; L1 ; none ; j # V6.0 (😂) FACE WITH TEARS OF JOY
437    26A1  ;  emoji  ;  L1  ;  none  ;  a j  # V4.0 (⚡) HIGH VOLTAGE SIGN
438    "#;
439
440        let file = create_temp_file(test_data);
441        let result = load_emoji_sequences(file.path()).unwrap();
442
443        let mut expected = HashMap::new();
444        let _ = expected
445            .insert("⚡".to_string(), "high-voltage-sign".to_string());
446        let _ = expected.insert(
447            "😂".to_string(),
448            "face-with-tears-of-joy".to_string(),
449        );
450        assert_eq!(result, expected);
451    }
452
453    #[cfg_attr(
454        miri,
455        ignore = "touches the filesystem; Miri isolation forbids it"
456    )]
457    #[test]
458    fn test_load_emoji_sequences_parenthesis_variations() {
459        let test_data = r#"
460    26A1 ; emoji ; L1 ; none ; a j # (⚡) HIGH VOLTAGE
461    1F602 ; emoji ; L1 ; none ; j # V6.0 (😂) FACE WITH TEARS
462    1F603 ; emoji ; L1 ; none ; j # V6.0 (😃) SMILEY FACE
463    1F604 ; emoji ; L1 ; none ; j # V6.0 (😄) GRINNING FACE
464    "#;
465
466        let file = create_temp_file(test_data);
467        let result = load_emoji_sequences(file.path()).unwrap();
468
469        let mut expected = HashMap::new();
470        let _ = expected
471            .insert("⚡".to_string(), "high-voltage".to_string());
472        let _ = expected
473            .insert("😂".to_string(), "face-with-tears".to_string());
474        let _ = expected
475            .insert("😃".to_string(), "smiley-face".to_string());
476        let _ = expected
477            .insert("😄".to_string(), "grinning-face".to_string());
478        assert_eq!(result, expected);
479    }
480
481    #[cfg_attr(
482        miri,
483        ignore = "touches the filesystem; Miri isolation forbids it"
484    )]
485    #[test]
486    fn test_load_emoji_sequences_unparseable_sequences() {
487        let test_data = r#"
488    110000 ; emoji ; L1 ; none ; j # Above Unicode range INVALID
489    1F602 ; emoji ; L1 ; none ; j # V6.0 (😂) FACE WITH TEARS OF JOY
490    D800 ; emoji ; L1 ; none ; j # Surrogate code point
491    "#;
492
493        let file = create_temp_file(test_data);
494        let result = load_emoji_sequences(file.path()).unwrap();
495
496        // Only the valid emoji should be included
497        let mut expected = HashMap::new();
498        let _ = expected.insert(
499            "😂".to_string(),
500            "face-with-tears-of-joy".to_string(),
501        );
502        assert_eq!(result, expected);
503    }
504
505    #[cfg_attr(
506        miri,
507        ignore = "touches the filesystem; Miri isolation forbids it"
508    )]
509    #[test]
510    fn test_load_emoji_sequences_empty_fields() {
511        let test_data = r#"
512    ; ; ; ; ; # Empty fields should be skipped
513    1F602 ; emoji ; L1 ; none ; j # V6.0 (😂) FACE WITH TEARS OF JOY
514    #
515    "#;
516
517        let file = create_temp_file(test_data);
518        let result = load_emoji_sequences(file.path()).unwrap();
519
520        let mut expected = HashMap::new();
521        let _ = expected.insert(
522            "😂".to_string(),
523            "face-with-tears-of-joy".to_string(),
524        );
525        assert_eq!(result, expected);
526    }
527
528    #[cfg_attr(
529        miri,
530        ignore = "touches the filesystem; Miri isolation forbids it"
531    )]
532    #[test]
533    fn test_load_emoji_sequences_whitespace_variations() {
534        let test_data = r#"
535    1F602;emoji;L1;none;j# V6.0 (😂) FACE WITH TEARS OF JOY
536    1F603  ;  emoji  ;  L1  ;  none  ;  j  # V6.0 (😃) SMILEY FACE
537    "#;
538
539        let file = create_temp_file(test_data);
540        let result = load_emoji_sequences(file.path()).unwrap();
541
542        let mut expected = HashMap::new();
543        let _ = expected.insert(
544            "😂".to_string(),
545            "face-with-tears-of-joy".to_string(),
546        );
547        let _ = expected
548            .insert("😃".to_string(), "smiley-face".to_string());
549        assert_eq!(result, expected);
550    }
551}