//! GitHub-style emoji shortcodes for commit text and markdown text (`:sparkles:` becomes ✨). //! //! Lookup order: the gitmoji table (`gitmojis.json`, from //! , MIT, see `gitmojis.LICENSE`), then the //! `emojis` crate's GitHub shortcodes as a fallback. //! //! Only display paths call this. The stored data, links and anchors are never changed. use std::borrow::Cow; use std::collections::HashMap; use std::sync::LazyLock; /// Gitmoji table keyed by the bare name (`sparkles`, not `:sparkles:`). static GITMOJIS: LazyLock> = LazyLock::new(|| { let doc: serde_json::Value = serde_json::from_str(include_str!("gitmojis.json")).expect("embedded gitmojis.json"); doc["gitmojis"] .as_array() .into_iter() .flatten() .filter_map(|entry| { let code = entry["code"].as_str()?.trim_matches(':'); let emoji = entry["emoji"].as_str()?; Some((code.to_string(), emoji.to_string())) }) .collect() }); /// Emoji for a bare shortcode name, from the gitmoji table first, then the shortcode crate. fn lookup(name: &str) -> Option<&str> { GITMOJIS .get(name) .map(String::as_str) .or_else(|| emojis::get_by_shortcode(name).map(emojis::Emoji::as_str)) } /// Replaces every `:name:` whose `name` is a known GitHub shortcode with its emoji. /// /// `name` must match `[a-z0-9_+-]+`. Unknown codes and anything else are left as written. /// Returns `Cow::Borrowed` when nothing changes. pub fn emojify(text: &str) -> Cow<'_, str> { if !text.contains(':') { return Cow::Borrowed(text); } let bytes = text.as_bytes(); let mut out = String::new(); let mut copied = 0; let mut i = 0; while let Some(off) = text[i..].find(':') { let open = i + off; let name_start = open + 1; let mut name_end = name_start; while name_end < bytes.len() && is_name_byte(bytes[name_end]) { name_end += 1; } if name_end > name_start && name_end < bytes.len() && bytes[name_end] == b':' && let Some(emoji) = lookup(&text[name_start..name_end]) { out.push_str(&text[copied..open]); out.push_str(emoji); // The closing colon is consumed with the code, so scanning resumes after it. copied = name_end + 1; i = name_end + 1; } else { i = open + 1; } } if copied == 0 { return Cow::Borrowed(text); } out.push_str(&text[copied..]); Cow::Owned(out) } fn is_name_byte(b: u8) -> bool { b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'+' | b'-') } #[cfg(test)] mod tests { use super::*; #[test] fn known_codes_convert() { assert_eq!(emojify("feat: :sparkles: init"), "feat: ✨ init"); assert_eq!(emojify(":tada::sparkles:"), "🎉✨"); assert_eq!(emojify(":+1:"), "👍"); } #[test] fn unknown_and_non_codes_stay() { assert_eq!(emojify(":spark:"), ":spark:"); assert_eq!(emojify("::"), "::"); assert_eq!(emojify(":a b:"), ":a b:"); assert_eq!(emojify("built at 12:30:45"), "built at 12:30:45"); assert_eq!( emojify("see https://example.com:8080/x"), "see https://example.com:8080/x" ); assert_eq!(emojify("Sparkles:"), "Sparkles:"); } #[test] fn every_gitmoji_code_converts() { let doc: serde_json::Value = serde_json::from_str(include_str!("gitmojis.json")).expect("valid json"); let entries = doc["gitmojis"].as_array().expect("gitmojis array"); assert!(!entries.is_empty()); for entry in entries { let code = entry["code"].as_str().expect("code"); let emoji = entry["emoji"].as_str().expect("emoji"); assert_eq!(emojify(code), emoji, "gitmoji {code}"); } } #[test] fn no_colon_is_borrowed() { assert!(matches!(emojify("plain text"), Cow::Borrowed(_))); assert!(matches!(emojify(":spark:"), Cow::Borrowed(_))); } }