type Condition<'a> = &'a dyn Fn(&[u8]) -> bool;
struct Rule<'a> {
suffix: &'a [u8],
replacement: &'a [u8],
condition: Option<Condition<'a>>,
}
impl<'a> Rule<'a> {
fn new(suffix: &'a str, replacement: &'a str, condition: Option<Condition<'a>>) -> Self {
Rule {
suffix: suffix.as_bytes(),
replacement: replacement.as_bytes(),
condition,
}
}
}
const DOUBLE_CONSONANT: &[u8] = b"*d";
#[cfg(test)]
pub(crate) fn stem(word: String) -> String {
let mut w = word.into_bytes();
stem_in_place(&mut w);
String::from_utf8(w).expect("stemming keeps ASCII input ASCII")
}
pub(crate) fn stem_in_place(word: &mut Vec<u8>) {
if let Some(stem) = std::str::from_utf8(word).ok().and_then(irregular_form) {
word.clear();
word.extend_from_slice(stem.as_bytes());
return;
}
if word.len() <= 2 {
return;
}
step1a(word);
step1b(word);
step1c(word);
step2(word);
step3(word);
step4(word);
step5a(word);
step5b(word);
}
fn irregular_form(word: &str) -> Option<&'static str> {
Some(match word {
"sky" | "skies" => "sky",
"dying" => "die",
"lying" => "lie",
"tying" => "tie",
"news" => "news",
"innings" | "inning" => "inning",
"outings" | "outing" => "outing",
"cannings" | "canning" => "canning",
"howe" => "howe",
"proceed" => "proceed",
"exceed" => "exceed",
"succeed" => "succeed",
_ => return None,
})
}
fn is_vowel(c: u8) -> bool {
matches!(c, b'a' | b'e' | b'i' | b'o' | b'u')
}
fn consonant_flags(word: &[u8]) -> impl Iterator<Item = bool> + '_ {
let mut previous = false;
word.iter().enumerate().map(move |(i, &c)| {
let flag = if is_vowel(c) {
false
} else if c == b'y' {
i == 0 || !previous
} else {
true
};
previous = flag;
flag
})
}
fn is_consonant(word: &[u8], i: usize) -> bool {
consonant_flags(&word[..=i]).last().unwrap_or(false)
}
fn measure(stem: &[u8]) -> usize {
let mut count = 0;
let mut previous_is_vowel = false;
for is_cons in consonant_flags(stem) {
if is_cons && previous_is_vowel {
count += 1;
}
previous_is_vowel = !is_cons;
}
count
}
fn has_positive_measure(stem: &[u8]) -> bool {
measure(stem) > 0
}
fn measure_gt_1(stem: &[u8]) -> bool {
measure(stem) > 1
}
fn contains_vowel(stem: &[u8]) -> bool {
consonant_flags(stem).any(|is_cons| !is_cons)
}
fn ends_double_consonant(word: &[u8]) -> bool {
let n = word.len();
n >= 2 && word[n - 1] == word[n - 2] && is_consonant(word, n - 1)
}
fn ends_cvc(word: &[u8]) -> bool {
let n = word.len();
(n >= 3
&& is_consonant(word, n - 3)
&& !is_consonant(word, n - 2)
&& is_consonant(word, n - 1)
&& !matches!(word[n - 1], b'w' | b'x' | b'y'))
|| (n == 2 && !is_consonant(word, 0) && is_consonant(word, 1))
}
fn replace_tail(word: &mut Vec<u8>, stem_len: usize, replacement: &[u8]) {
word.truncate(stem_len);
word.extend_from_slice(replacement);
}
fn apply_rules(word: &mut Vec<u8>, rules: &[Rule]) {
for rule in rules {
let stem_len = if rule.suffix == DOUBLE_CONSONANT && ends_double_consonant(word) {
word.len() - 2
} else if word.ends_with(rule.suffix) {
word.len() - rule.suffix.len()
} else {
continue;
};
if rule
.condition
.is_none_or(|condition| condition(&word[..stem_len]))
{
replace_tail(word, stem_len, rule.replacement);
}
return;
}
}
fn step1a(word: &mut Vec<u8>) {
if word.len() == 4 && word.ends_with(b"ies") {
replace_tail(word, 1, b"ie");
return;
}
apply_rules(
word,
&[
Rule::new("sses", "ss", None),
Rule::new("ies", "i", None),
Rule::new("ss", "ss", None),
Rule::new("s", "", None),
],
);
}
fn step1b(word: &mut Vec<u8>) {
if word.ends_with(b"ied") {
let replacement: &[u8] = if word.len() == 4 { b"ie" } else { b"i" };
replace_tail(word, word.len() - 3, replacement);
return;
}
if word.ends_with(b"eed") {
let stem_len = word.len() - 3;
if measure(&word[..stem_len]) > 0 {
replace_tail(word, stem_len, b"ee");
}
return;
}
let suffix_len = if word.ends_with(b"ed") {
2
} else if word.ends_with(b"ing") {
3
} else {
return;
};
if !contains_vowel(&word[..word.len() - suffix_len]) {
return;
}
word.truncate(word.len() - suffix_len);
let last = [word[word.len() - 1]];
let keep_double = |_: &[u8]| !matches!(last[0], b'l' | b's' | b'z');
let short_syllable = |stem: &[u8]| measure(stem) == 1 && ends_cvc(stem);
apply_rules(
word,
&[
Rule::new("at", "ate", None),
Rule::new("bl", "ble", None),
Rule::new("iz", "ize", None),
Rule {
suffix: DOUBLE_CONSONANT,
replacement: &last,
condition: Some(&keep_double),
},
Rule::new("", "e", Some(&short_syllable)),
],
);
}
fn step1c(word: &mut Vec<u8>) {
let condition = |stem: &[u8]| stem.len() > 1 && is_consonant(stem, stem.len() - 1);
apply_rules(word, &[Rule::new("y", "i", Some(&condition))]);
}
fn step2(word: &mut Vec<u8>) {
if word.ends_with(b"alli") && has_positive_measure(&word[..word.len() - 4]) {
replace_tail(word, word.len() - 4, b"al");
step2(word);
return;
}
let positive: Condition = &has_positive_measure;
let logi_applies = word.len() >= 3 && has_positive_measure(&word[..word.len() - 3]);
let logi = |_: &[u8]| logi_applies;
apply_rules(
word,
&[
Rule::new("ational", "ate", Some(positive)),
Rule::new("tional", "tion", Some(positive)),
Rule::new("enci", "ence", Some(positive)),
Rule::new("anci", "ance", Some(positive)),
Rule::new("izer", "ize", Some(positive)),
Rule::new("bli", "ble", Some(positive)),
Rule::new("alli", "al", Some(positive)),
Rule::new("entli", "ent", Some(positive)),
Rule::new("eli", "e", Some(positive)),
Rule::new("ousli", "ous", Some(positive)),
Rule::new("ization", "ize", Some(positive)),
Rule::new("ation", "ate", Some(positive)),
Rule::new("ator", "ate", Some(positive)),
Rule::new("alism", "al", Some(positive)),
Rule::new("iveness", "ive", Some(positive)),
Rule::new("fulness", "ful", Some(positive)),
Rule::new("ousness", "ous", Some(positive)),
Rule::new("aliti", "al", Some(positive)),
Rule::new("iviti", "ive", Some(positive)),
Rule::new("biliti", "ble", Some(positive)),
Rule::new("fulli", "ful", Some(positive)),
Rule::new("logi", "log", Some(&logi)),
],
);
}
fn step3(word: &mut Vec<u8>) {
let positive: Condition = &has_positive_measure;
apply_rules(
word,
&[
Rule::new("icate", "ic", Some(positive)),
Rule::new("ative", "", Some(positive)),
Rule::new("alize", "al", Some(positive)),
Rule::new("iciti", "ic", Some(positive)),
Rule::new("ical", "ic", Some(positive)),
Rule::new("ful", "", Some(positive)),
Rule::new("ness", "", Some(positive)),
],
);
}
fn step4(word: &mut Vec<u8>) {
let gt1: Condition = &measure_gt_1;
let ion = |stem: &[u8]| measure(stem) > 1 && matches!(stem.last(), Some(b's' | b't'));
apply_rules(
word,
&[
Rule::new("al", "", Some(gt1)),
Rule::new("ance", "", Some(gt1)),
Rule::new("ence", "", Some(gt1)),
Rule::new("er", "", Some(gt1)),
Rule::new("ic", "", Some(gt1)),
Rule::new("able", "", Some(gt1)),
Rule::new("ible", "", Some(gt1)),
Rule::new("ant", "", Some(gt1)),
Rule::new("ement", "", Some(gt1)),
Rule::new("ment", "", Some(gt1)),
Rule::new("ent", "", Some(gt1)),
Rule::new("ion", "", Some(&ion)),
Rule::new("ou", "", Some(gt1)),
Rule::new("ism", "", Some(gt1)),
Rule::new("ate", "", Some(gt1)),
Rule::new("iti", "", Some(gt1)),
Rule::new("ous", "", Some(gt1)),
Rule::new("ive", "", Some(gt1)),
Rule::new("ize", "", Some(gt1)),
],
);
}
fn step5a(word: &mut Vec<u8>) {
if word.ends_with(b"e") {
let stem = &word[..word.len() - 1];
let m = measure(stem);
if m > 1 || (m == 1 && !ends_cvc(stem)) {
word.pop();
}
}
}
fn step5b(word: &mut Vec<u8>) {
let applies = !word.is_empty() && measure(&word[..word.len() - 1]) > 1;
let condition = |_: &[u8]| applies;
apply_rules(word, &[Rule::new("ll", "l", Some(&condition))]);
}
#[cfg(test)]
mod tests {
fn stem(word: &str) -> String {
super::stem(word.to_owned())
}
#[test]
fn stems_classic_examples() {
let cases = [
("caresses", "caress"),
("ponies", "poni"),
("ties", "tie"),
("cats", "cat"),
("feed", "feed"),
("agreed", "agre"),
("plastered", "plaster"),
("motoring", "motor"),
("conflated", "conflat"),
("hopping", "hop"),
("falling", "fall"),
("filing", "file"),
("happy", "happi"),
("relational", "relat"),
("conditional", "condit"),
("generalization", "gener"),
("electrical", "electr"),
("adjustment", "adjust"),
("controlling", "control"),
("generously", "gener"),
];
for (word, expected) in cases {
assert_eq!(stem(word), expected, "stem({word:?})");
}
}
#[test]
fn uses_nltk_irregular_forms() {
assert_eq!(stem("dying"), "die");
assert_eq!(stem("skies"), "sky");
assert_eq!(stem("innings"), "inning");
assert_eq!(stem("succeed"), "succeed");
}
#[test]
fn leaves_short_words_alone() {
assert_eq!(stem("as"), "as");
assert_eq!(stem("is"), "is");
}
}