use regex::Regex;
use std::sync::LazyLock;
static ICON_SHORTCODE_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r":([a-z][a-z0-9_]*(?:-[a-z0-9_]+)+):").unwrap());
static EMOJI_SHORTCODE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r":([a-zA-Z0-9_+-]+):").unwrap());
pub const ICON_SET_PREFIXES: &[&str] = &["material", "octicons", "fontawesome", "simple", "custom"];
#[derive(Debug, Clone, PartialEq)]
pub struct IconShortcode {
pub full_text: String,
pub prefix: String,
pub name_parts: Vec<String>,
pub start: usize,
pub end: usize,
}
impl IconShortcode {
pub fn full_name(&self) -> String {
if self.name_parts.is_empty() {
self.prefix.clone()
} else {
format!("{}-{}", self.prefix, self.name_parts.join("-"))
}
}
pub fn is_known_icon_set(&self) -> bool {
ICON_SET_PREFIXES.iter().any(|&p| self.prefix.starts_with(p))
}
}
#[inline]
pub fn contains_icon_shortcode(line: &str) -> bool {
if !line.contains(':') {
return false;
}
ICON_SHORTCODE_PATTERN.is_match(line)
}
#[inline]
pub fn contains_any_shortcode(line: &str) -> bool {
if !line.contains(':') {
return false;
}
ICON_SHORTCODE_PATTERN.is_match(line) || EMOJI_SHORTCODE_PATTERN.is_match(line)
}
pub fn find_icon_shortcodes(line: &str) -> Vec<IconShortcode> {
if !line.contains(':') {
return Vec::new();
}
let mut results = Vec::new();
for m in ICON_SHORTCODE_PATTERN.find_iter(line) {
let full_text = m.as_str().to_string();
let inner = &full_text[1..full_text.len() - 1];
let parts: Vec<&str> = inner.split('-').collect();
if parts.is_empty() {
continue;
}
let prefix = parts[0].to_string();
let name_parts: Vec<String> = parts[1..].iter().map(|&s| s.to_string()).collect();
results.push(IconShortcode {
full_text,
prefix,
name_parts,
start: m.start(),
end: m.end(),
});
}
results
}
pub fn is_in_icon_shortcode(line: &str, position: usize) -> bool {
for shortcode in find_icon_shortcodes(line) {
if shortcode.start <= position && position < shortcode.end {
return true;
}
}
false
}
pub fn is_in_any_shortcode(line: &str, position: usize) -> bool {
if !line.contains(':') {
return false;
}
for m in ICON_SHORTCODE_PATTERN.find_iter(line) {
if m.start() <= position && position < m.end() {
return true;
}
}
for m in EMOJI_SHORTCODE_PATTERN.find_iter(line) {
if m.start() <= position && position < m.end() {
return true;
}
}
false
}
pub fn mask_icon_shortcodes(line: &str) -> String {
if !line.contains(':') {
return line.to_string();
}
let mut result = line.to_string();
let shortcodes = find_icon_shortcodes(line);
for shortcode in shortcodes.into_iter().rev() {
let replacement = " ".repeat(shortcode.end - shortcode.start);
result.replace_range(shortcode.start..shortcode.end, &replacement);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_contains_icon_shortcode() {
assert!(contains_icon_shortcode("Check :material-check: this"));
assert!(contains_icon_shortcode(":octicons-mark-github-16:"));
assert!(contains_icon_shortcode(":fontawesome-brands-github:"));
assert!(contains_icon_shortcode(":fontawesome-solid-star:"));
assert!(contains_icon_shortcode(":simple-github:"));
assert!(!contains_icon_shortcode(":smile:"));
assert!(!contains_icon_shortcode(":thumbsup:"));
assert!(!contains_icon_shortcode("No icons here"));
assert!(!contains_icon_shortcode("Just text"));
}
#[test]
fn test_find_icon_shortcodes_material() {
let shortcodes = find_icon_shortcodes("Click :material-check: to confirm");
assert_eq!(shortcodes.len(), 1);
assert_eq!(shortcodes[0].full_text, ":material-check:");
assert_eq!(shortcodes[0].prefix, "material");
assert_eq!(shortcodes[0].name_parts, vec!["check"]);
assert!(shortcodes[0].is_known_icon_set());
}
#[test]
fn test_find_icon_shortcodes_octicons() {
let shortcodes = find_icon_shortcodes(":octicons-mark-github-16:");
assert_eq!(shortcodes.len(), 1);
assert_eq!(shortcodes[0].prefix, "octicons");
assert_eq!(shortcodes[0].name_parts, vec!["mark", "github", "16"]);
assert!(shortcodes[0].is_known_icon_set());
}
#[test]
fn test_find_icon_shortcodes_fontawesome() {
let shortcodes = find_icon_shortcodes(":fontawesome-brands-github:");
assert_eq!(shortcodes.len(), 1);
assert_eq!(shortcodes[0].prefix, "fontawesome");
assert_eq!(shortcodes[0].name_parts, vec!["brands", "github"]);
let shortcodes = find_icon_shortcodes(":fontawesome-solid-star:");
assert_eq!(shortcodes.len(), 1);
assert_eq!(shortcodes[0].name_parts, vec!["solid", "star"]);
}
#[test]
fn test_find_icon_shortcodes_multiple() {
let shortcodes = find_icon_shortcodes(":material-check: and :material-close:");
assert_eq!(shortcodes.len(), 2);
assert_eq!(shortcodes[0].full_text, ":material-check:");
assert_eq!(shortcodes[1].full_text, ":material-close:");
}
#[test]
fn test_icon_shortcode_full_name() {
let shortcodes = find_icon_shortcodes(":octicons-mark-github-16:");
assert_eq!(shortcodes[0].full_name(), "octicons-mark-github-16");
}
#[test]
fn test_is_in_icon_shortcode() {
let line = "Text :material-check: more text";
assert!(!is_in_icon_shortcode(line, 0)); assert!(!is_in_icon_shortcode(line, 4)); assert!(is_in_icon_shortcode(line, 5)); assert!(is_in_icon_shortcode(line, 10)); assert!(is_in_icon_shortcode(line, 20)); assert!(!is_in_icon_shortcode(line, 21)); }
#[test]
fn test_mask_icon_shortcodes() {
let line = "Text :material-check: more";
let masked = mask_icon_shortcodes(line);
assert_eq!(masked, "Text more");
assert_eq!(masked.len(), line.len());
let line2 = ":material-a: and :material-b:";
let masked2 = mask_icon_shortcodes(line2);
assert!(!masked2.contains(":material"));
assert_eq!(masked2.len(), line2.len());
}
#[test]
fn test_shortcode_positions() {
let line = "Pre :material-check: post";
let shortcodes = find_icon_shortcodes(line);
assert_eq!(shortcodes.len(), 1);
assert_eq!(shortcodes[0].start, 4);
assert_eq!(shortcodes[0].end, 20);
assert_eq!(&line[shortcodes[0].start..shortcodes[0].end], ":material-check:");
}
#[test]
fn test_unknown_icon_set() {
let shortcodes = find_icon_shortcodes(":custom-my-icon:");
assert_eq!(shortcodes.len(), 1);
assert_eq!(shortcodes[0].prefix, "custom");
assert!(shortcodes[0].is_known_icon_set());
let shortcodes = find_icon_shortcodes(":unknown-prefix-icon:");
assert_eq!(shortcodes.len(), 1);
assert!(!shortcodes[0].is_known_icon_set());
}
#[test]
fn test_emoji_vs_icon() {
assert!(!contains_icon_shortcode(":smile:"));
assert!(!contains_icon_shortcode(":+1:"));
assert!(contains_icon_shortcode(":material-check:"));
assert!(contains_any_shortcode(":smile:"));
assert!(contains_any_shortcode(":material-check:"));
}
#[test]
fn test_is_in_any_shortcode() {
let line = ":smile: and :material-check:";
assert!(is_in_any_shortcode(line, 0)); assert!(is_in_any_shortcode(line, 3)); assert!(is_in_any_shortcode(line, 6));
assert!(!is_in_any_shortcode(line, 7)); assert!(!is_in_any_shortcode(line, 10));
assert!(is_in_any_shortcode(line, 12)); assert!(is_in_any_shortcode(line, 20)); }
#[test]
fn test_underscore_in_icon_names() {
let shortcodes = find_icon_shortcodes(":material-file_download:");
assert_eq!(shortcodes.len(), 1);
assert_eq!(shortcodes[0].name_parts, vec!["file_download"]);
}
}