use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::LazyLock;
use regex::Regex;
pub fn singularize(text: &str) -> String {
if text.is_empty() {
return String::new();
}
let singular = pluralizer::pluralize(text, 1, false);
if singular.is_empty() {
text.to_string()
} else {
singular
}
}
const STEM_CACHE_CAP: usize = 4096;
thread_local! {
static STEM_CACHE: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
}
pub(crate) fn stem_key(word: &str) -> String {
if let Some(hit) = STEM_CACHE.with(|c| c.borrow().get(word).cloned()) {
return hit;
}
let key = stem(&singularize(word));
STEM_CACHE.with(|c| {
let mut cache = c.borrow_mut();
if cache.len() < STEM_CACHE_CAP {
cache.insert(word.to_string(), key.clone());
}
});
key
}
pub fn is_plural(text: &str) -> bool {
let ends_with_s = text
.chars()
.last()
.map(|c| c.eq_ignore_ascii_case(&'s'))
.unwrap_or(false);
let differs_from_singular = text.to_lowercase() != singularize(text).to_lowercase();
differs_from_singular || ends_with_s
}
static GT0: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^([^aeiou][^aeiouy]*)?([aeiouy][aeiou]*)([^aeiou][^aeiouy]*)").unwrap()
});
static EQ1: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^([^aeiou][^aeiouy]*)?([aeiouy][aeiou]*)([^aeiou][^aeiouy]*)([aeiouy][aeiou]*)?$")
.unwrap()
});
static GT1: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^([^aeiou][^aeiouy]*)?(([aeiouy][aeiou]*)([^aeiou][^aeiouy]*)){2,}").unwrap()
});
static VOWEL_IN_STEM: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^([^aeiou][^aeiouy]*)?[aeiouy]").unwrap());
static CONSONANT_LIKE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^([^aeiou][^aeiouy]*)[aeiouy][^aeiouwxy]$").unwrap());
static SFX_LL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"ll$").unwrap());
static SFX_E: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(.+?)e$").unwrap());
static SFX_Y: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(.+?)y$").unwrap());
static SFX_ION: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(.+?(s|t))(ion)$").unwrap());
static SFX_ED_OR_ING: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(.+?)(ed|ing)$").unwrap());
static SFX_AT_BL_IZ: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(at|bl|iz)$").unwrap());
static SFX_EED: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(.+?)eed$").unwrap());
static SFX_S: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^.+?[^s]s$").unwrap());
static SFX_SSES_OR_IES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^.+?(ss|i)es$").unwrap());
static STEP2: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$").unwrap()
});
static STEP3: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$").unwrap());
static STEP4: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$",
)
.unwrap()
});
fn step2list(suffix: &str) -> &'static str {
match suffix {
"ational" => "ate",
"tional" => "tion",
"enci" => "ence",
"anci" => "ance",
"izer" => "ize",
"bli" => "ble",
"alli" => "al",
"entli" => "ent",
"eli" => "e",
"ousli" => "ous",
"ization" => "ize",
"ation" => "ate",
"ator" => "ate",
"alism" => "al",
"iveness" => "ive",
"fulness" => "ful",
"ousness" => "ous",
"aliti" => "al",
"iviti" => "ive",
"biliti" => "ble",
"logi" => "log",
_ => "",
}
}
fn step3list(suffix: &str) -> &'static str {
match suffix {
"icate" => "ic",
"ative" => "",
"alize" => "al",
"iciti" => "ic",
"ical" => "ic",
"ful" => "",
"ness" => "",
_ => "",
}
}
fn multi_consonant_like(s: &str) -> bool {
let b = s.as_bytes();
let n = b.len();
if n < 2 {
return false;
}
let last = b[n - 1];
last == b[n - 2]
&& !matches!(
last,
b'a' | b'e' | b'i' | b'o' | b'u' | b'y' | b'l' | b's' | b'z'
)
}
pub(crate) fn stem(value: &str) -> String {
let mut value = value.to_lowercase();
if value.chars().count() < 3 {
return value;
}
let first_char_was_y = value.as_bytes()[0] == b'y';
if first_char_was_y {
value = format!("Y{}", &value[1..]);
}
if SFX_SSES_OR_IES.is_match(&value) {
value.truncate(value.len() - 2);
} else if SFX_S.is_match(&value) {
value.truncate(value.len() - 1);
}
if SFX_EED.is_match(&value) {
let stem = SFX_EED.replace(&value, "$1").into_owned();
if GT0.is_match(&stem) {
value.truncate(value.len() - 1);
}
} else if SFX_ED_OR_ING.is_match(&value) {
let stem = SFX_ED_OR_ING.replace(&value, "$1").into_owned();
if VOWEL_IN_STEM.is_match(&stem) {
value = stem;
if SFX_AT_BL_IZ.is_match(&value) {
value.push('e');
} else if multi_consonant_like(&value) {
value.truncate(value.len() - 1);
} else if CONSONANT_LIKE.is_match(&value) {
value.push('e');
}
}
}
if SFX_Y.is_match(&value) {
let stem = SFX_Y.replace(&value, "$1").into_owned();
if VOWEL_IN_STEM.is_match(&stem) {
value = format!("{stem}i");
}
}
let next = STEP2.captures(&value).and_then(|caps| {
let stem = caps[1].to_string();
if GT0.is_match(&stem) {
Some(format!("{stem}{}", step2list(&caps[2])))
} else {
None
}
});
if let Some(n) = next {
value = n;
}
let next = STEP3.captures(&value).and_then(|caps| {
let stem = caps[1].to_string();
if GT0.is_match(&stem) {
Some(format!("{stem}{}", step3list(&caps[2])))
} else {
None
}
});
if let Some(n) = next {
value = n;
}
let next = STEP4.captures(&value).and_then(|caps| {
let stem = caps[1].to_string();
if GT1.is_match(&stem) {
Some(stem)
} else {
None
}
});
if let Some(n) = next {
value = n;
}
let next = SFX_ION.captures(&value).and_then(|caps| {
let stem = caps[1].to_string();
if GT1.is_match(&stem) {
Some(stem)
} else {
None
}
});
if let Some(n) = next {
value = n;
}
let next = SFX_E.captures(&value).and_then(|caps| {
let stem = caps[1].to_string();
if GT1.is_match(&stem) || (EQ1.is_match(&stem) && !CONSONANT_LIKE.is_match(&stem)) {
Some(stem)
} else {
None
}
});
if let Some(n) = next {
value = n;
}
if SFX_LL.is_match(&value) && GT1.is_match(&value) {
value.truncate(value.len() - 1);
}
if first_char_was_y {
value = format!("y{}", &value[1..]);
}
value
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stems_match_original_toolchain() {
let cases = [
("follow", "follow"),
("follows", "follow"),
("following", "follow"),
("followed", "follow"),
("stalk", "stalk"),
("stalking", "stalk"),
("watch", "watch"),
("watches", "watch"),
("watching", "watch"),
("creates", "creat"),
("created", "creat"),
("create", "creat"),
("sends", "send"),
("sent", "sent"),
("sended", "send"),
("mailed", "mail"),
("got", "got"),
("received", "receiv"),
("retrieved", "retriev"),
("recieved", "reciev"),
("messaged", "messag"),
("msgs", "msg"),
("contacted", "contact"),
("contacts", "contact"),
];
for (word, expected) in cases {
assert_eq!(stem(&singularize(word)), expected, "key for {word:?}");
}
}
#[test]
fn singularizes_grammar_nouns() {
assert_eq!(singularize("users"), "user");
assert_eq!(singularize("messages"), "message");
assert_eq!(singularize("content"), "content");
}
#[test]
fn detects_plurals() {
assert!(is_plural("cats"));
assert!(is_plural("users"));
assert!(!is_plural("cat"));
assert!(!is_plural("user"));
}
}