forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
4.1 KiB
1//! GitHub-style emoji shortcodes for commit text and markdown text (`:sparkles:` becomes ✨).
2//!
3//! Lookup order: the gitmoji table (`gitmojis.json`, from
4//! <https://github.com/carloscuesta/gitmoji>, MIT, see `gitmojis.LICENSE`), then the
5//! `emojis` crate's GitHub shortcodes as a fallback.
6//!
7//! Only display paths call this. The stored data, links and anchors are never changed.
8
9use std::borrow::Cow;
10use std::collections::HashMap;
11use std::sync::LazyLock;
12
13/// Gitmoji table keyed by the bare name (`sparkles`, not `:sparkles:`).
14static GITMOJIS: LazyLock<HashMap<String, String>> = LazyLock::new(|| {
15 let doc: serde_json::Value =
16 serde_json::from_str(include_str!("gitmojis.json")).expect("embedded gitmojis.json");
17 doc["gitmojis"]
18 .as_array()
19 .into_iter()
20 .flatten()
21 .filter_map(|entry| {
22 let code = entry["code"].as_str()?.trim_matches(':');
23 let emoji = entry["emoji"].as_str()?;
24 Some((code.to_string(), emoji.to_string()))
25 })
26 .collect()
27});
28
29/// Emoji for a bare shortcode name, from the gitmoji table first, then the shortcode crate.
30fn lookup(name: &str) -> Option<&str> {
31 GITMOJIS
32 .get(name)
33 .map(String::as_str)
34 .or_else(|| emojis::get_by_shortcode(name).map(emojis::Emoji::as_str))
35}
36
37/// Replaces every `:name:` whose `name` is a known GitHub shortcode with its emoji.
38///
39/// `name` must match `[a-z0-9_+-]+`. Unknown codes and anything else are left as written.
40/// Returns `Cow::Borrowed` when nothing changes.
41pub fn emojify(text: &str) -> Cow<'_, str> {
42 if !text.contains(':') {
43 return Cow::Borrowed(text);
44 }
45 let bytes = text.as_bytes();
46 let mut out = String::new();
47 let mut copied = 0;
48 let mut i = 0;
49 while let Some(off) = text[i..].find(':') {
50 let open = i + off;
51 let name_start = open + 1;
52 let mut name_end = name_start;
53 while name_end < bytes.len() && is_name_byte(bytes[name_end]) {
54 name_end += 1;
55 }
56 if name_end > name_start
57 && name_end < bytes.len()
58 && bytes[name_end] == b':'
59 && let Some(emoji) = lookup(&text[name_start..name_end])
60 {
61 out.push_str(&text[copied..open]);
62 out.push_str(emoji);
63 // The closing colon is consumed with the code, so scanning resumes after it.
64 copied = name_end + 1;
65 i = name_end + 1;
66 } else {
67 i = open + 1;
68 }
69 }
70 if copied == 0 {
71 return Cow::Borrowed(text);
72 }
73 out.push_str(&text[copied..]);
74 Cow::Owned(out)
75}
76
77fn is_name_byte(b: u8) -> bool {
78 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'+' | b'-')
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn known_codes_convert() {
87 assert_eq!(emojify("feat: :sparkles: init"), "feat: ✨ init");
88 assert_eq!(emojify(":tada::sparkles:"), "🎉✨");
89 assert_eq!(emojify(":+1:"), "👍");
90 }
91
92 #[test]
93 fn unknown_and_non_codes_stay() {
94 assert_eq!(emojify(":spark:"), ":spark:");
95 assert_eq!(emojify("::"), "::");
96 assert_eq!(emojify(":a b:"), ":a b:");
97 assert_eq!(emojify("built at 12:30:45"), "built at 12:30:45");
98 assert_eq!(
99 emojify("see https://example.com:8080/x"),
100 "see https://example.com:8080/x"
101 );
102 assert_eq!(emojify("Sparkles:"), "Sparkles:");
103 }
104
105 #[test]
106 fn every_gitmoji_code_converts() {
107 let doc: serde_json::Value =
108 serde_json::from_str(include_str!("gitmojis.json")).expect("valid json");
109 let entries = doc["gitmojis"].as_array().expect("gitmojis array");
110 assert!(!entries.is_empty());
111 for entry in entries {
112 let code = entry["code"].as_str().expect("code");
113 let emoji = entry["emoji"].as_str().expect("emoji");
114 assert_eq!(emojify(code), emoji, "gitmoji {code}");
115 }
116 }
117
118 #[test]
119 fn no_colon_is_borrowed() {
120 assert!(matches!(emojify("plain text"), Cow::Borrowed(_)));
121 assert!(matches!(emojify(":spark:"), Cow::Borrowed(_)));
122 }
123}