use lazy_static::lazy_static;
const HASHES_RE_STR: &str = r"[\u{23}\u{FE5F}\u{FF03}]";
lazy_static! {
static ref CONTINUATION_CHAR_RE_STRING: String = format!(
r"[\p{{XID_Continue}}\p{{Extended_Pictographic}}\p{{Emoji_Component}}[-+_]--{}]",
HASHES_RE_STR
);
pub static ref HASHTAG_RE_STRING: String = format!(r#"(?x)
(
# having the string start "character" just before the hash is okay
^
|
# otherwise, the only requirement is that the preceding character is
# NOT a continuation character
[^{}]
)
# The actual hashtag: a hash, then at least one tag char. No magic here! :)
(?P<hashtag>
(?P<hash>{})
(?P<tag>{}+)
)
"#,
CONTINUATION_CHAR_RE_STRING.as_str(),
HASHES_RE_STR,
CONTINUATION_CHAR_RE_STRING.as_str()
);
}
#[cfg(test)]
mod tests {
use itertools::Itertools;
use regex::Regex;
use test_case::test_case;
use super::*;
lazy_static! {
pub static ref HASHTAG_RE: Regex = Regex::new(&HASHTAG_RE_STRING).unwrap();
pub static ref HASHTAG_RE_EXACT: Regex = Regex::new(&format!(
"^{}$",
HASHTAG_RE_STRING.as_str(),
))
.unwrap();
}
#[test]
fn can_match_any_single_emoji_without_hashtag() {
let re_string = r"[\p{Extended_Pictographic}\p{Emoji_Component}]";
let single_emoji_regex = Regex::new(re_string).unwrap();
let results: Vec<bool> = emojic::grouped::all_variants()
.flatten()
.map(|e| single_emoji_regex.is_match(e.grapheme))
.collect();
let matched = results.iter().map(|&b| b as u32).sum::<u32>();
println!(
"Out of {} emojis, manged to match {}",
results.len(),
matched
);
assert_ne!(matched, 0, "Not a single emoji got matched?!");
}
#[test]
fn does_not_match_hash_without_tag() {
for starting_char in &['#', '﹟', '#'] {
let input = format!("Hello world, {} alone is not a hashtag!", starting_char);
assert!(
!HASHTAG_RE.is_match(&input),
"Wrongly matched \"{}\"",
input
);
}
}
#[test_case("revolution"; "simple ascii hashtag")]
#[test_case("神key"; "combined non-ascii and ascii hashtag")]
#[test_case("key神"; "combined ascii and non-ascii hashtag")]
#[test_case("hello🌍"; "combined ascii and emoji hashtag")]
#[test_case("🌍domination"; "combined emoji and ascii hashtag")]
#[test_case("🍕"; "In honour of ~dtBy's ceaseless pizza posting")]
fn test_hash_and_tag_get_found(tag: &str) -> Result<(), &'static str> {
for hash in &['#', '﹟', '#'] {
let input = format!(
"See this hashtag I just received {}{}. I have no idea what it means...",
hash, tag
);
let all_captures: Vec<regex::Captures> =
HASHTAG_RE.captures_iter(&input).into_iter().collect();
assert_eq!(all_captures.len(), 1);
let single_capture = all_captures
.get(0)
.ok_or("this shouldn't happen due to previous assert")?;
let capture_match = single_capture
.name("hash")
.ok_or("expected to capture hash symbol")?;
assert_eq!(capture_match.as_str(), hash.to_string());
let capture_match = single_capture
.name("tag")
.ok_or("expected to capture the actual tag")?;
assert_eq!(capture_match.as_str(), tag);
let capture_match = single_capture
.name("hashtag")
.ok_or("expected to capture full hashtag")?;
assert_eq!(capture_match.as_str(), format!("{}{}", hash, tag));
}
Ok(())
}
#[test_case("This is an example #text with tho #hashtags in it", vec!["text", "hashtags"]; "Two simple hashtags")]
#[test_case("Giving it a little #try#with#three consecutive tags", vec!["try"]; "Don't match hashtags just after tag")]
fn finding_hashtags(text: &str, expected_tags: Vec<&str>) -> Result<(), &'static str> {
let all_captures: Vec<regex::Captures> = HASHTAG_RE.captures_iter(text).collect();
assert_eq!(all_captures.len(), expected_tags.len());
for (single_capture, expected_tag) in all_captures.into_iter().zip(expected_tags) {
let capture_match = single_capture
.name("tag")
.ok_or("expected to capture the actual tag")?;
assert_eq!(capture_match.as_str(), expected_tag);
}
Ok(())
}
#[test]
fn double_hash_is_no_hashtag() {
for hash_1 in &['#', '﹟', '#'] {
for hash_2 in &['#', '﹟', '#'] {
let input = format!("This here: {}{} is not a hashtag!", hash_1, hash_2);
assert!(
!HASHTAG_RE.is_match(&input),
r#"Wrongly matched "{}" aka "{}""#,
input,
input.escape_unicode(),
);
}
}
}
#[test]
fn can_match_every_single_emoji_without_hashtag() {
let continuation_re =
Regex::new(&format!("^{}+$", CONTINUATION_CHAR_RE_STRING.as_str())).unwrap();
let hashes_re = Regex::new(HASHES_RE_STR).unwrap();
for emoji in emojic::grouped::all_variants().flatten().filter_map(|e| {
if !hashes_re.is_match(e.grapheme) {
Some(e.grapheme)
} else {
None
}
}) {
assert!(
continuation_re.is_match(emoji),
r#"Could not match "{}" aka "{}" as an emoji"#,
emoji,
emoji.escape_unicode(),
);
}
println!(
"Successfully matched {} emojis.",
emojic::grouped::all_variants().flatten().count()
)
}
#[test]
fn full_re_matches_each_emoji_grapheme_with_each_hash() {
let hashes_re = Regex::new(HASHES_RE_STR).unwrap();
let all_graphemes: Vec<&'static str> = emojic::grouped::all_variants()
.flatten()
.filter_map(|e| {
if !hashes_re.is_match(e.grapheme) {
Some(e.grapheme)
} else {
None
}
})
.collect();
println!("Will check {} combinations", 3 * all_graphemes.len());
for emoji in all_graphemes.to_owned() {
for (i, starting_char) in vec!['#', '﹟', '#'].iter().enumerate() {
let input = format!("{}{}", starting_char, &emoji);
assert!(
HASHTAG_RE_EXACT.is_match(&input),
"Input: \"{}\" aka \"{}\" (hash no. {})",
input,
input.escape_unicode(),
i
);
}
}
}
#[test]
fn full_re_matches_any_two_emojis_as_tag() -> Result<(), String> {
let hashes_re = Regex::new(HASHES_RE_STR).unwrap();
let all_pairs: Vec<Vec<&'static str>> = emojic::grouped::all_variants()
.flatten()
.filter_map(|e| {
if !hashes_re.is_match(e.grapheme) {
Some(e.grapheme)
} else {
None
}
})
.combinations(2)
.collect();
println!(
"Will try to match {} hashtags with two emojis",
all_pairs.len()
);
for v in all_pairs {
if v.len() != 2 {
return Err(format!(
"should be length 2, but is length {}",
v.len().to_string()
));
}
let e1 = *v.get(0).ok_or("wtf")?;
let e2 = *v.get(1).ok_or("wtf")?;
let tag = format!("{}{}", e1, e2);
let input = format!("#{}", &tag);
assert!(
HASHTAG_RE_EXACT.is_match(&input),
r#"Failed to match "{}" aka "{}" comprised of "{}" and "{}""#,
input,
input.escape_unicode(),
e1,
e2
);
}
Ok(())
}
}