use std::collections::HashSet;
use anyhow::{Context, Result};
use harper_core::spell::{Dictionary as HarperDictionary, MutableDictionary};
static ATTRIBUTES: &str = include_str!("../../dictionaries/affixes/attributes.json");
const MIN_LEMMA_CHARS: usize = 4;
const ALREADY_INFLECTED: [&str; 4] = ["s", "ed", "ing", "ly"];
const VERBAL_ENDINGS: [&str; 4] = ["ate", "ize", "ise", "ify"];
#[must_use]
pub fn flags_for(word: &str) -> Option<&'static str> {
if word.chars().count() < MIN_LEMMA_CHARS || !word.chars().all(|c| c.is_ascii_lowercase()) {
return None;
}
if ALREADY_INFLECTED
.iter()
.any(|ending| word.ends_with(ending))
{
return None;
}
if VERBAL_ENDINGS.iter().any(|ending| word.ends_with(ending)) && !doubles_final_consonant(word)
{
return Some("SdG");
}
Some("S")
}
fn doubles_final_consonant(word: &str) -> bool {
let tail: Vec<char> = word.chars().rev().take(3).collect();
let [last, middle, first] = tail[..] else {
return false;
};
is_consonant(first)
&& is_vowel(middle)
&& is_consonant(last)
&& !matches!(last, 'w' | 'x' | 'y')
}
const fn is_vowel(c: char) -> bool {
matches!(c, 'a' | 'e' | 'i' | 'o' | 'u')
}
const fn is_consonant(c: char) -> bool {
c.is_ascii_lowercase() && !is_vowel(c)
}
pub fn expand<'a>(lemmas: impl IntoIterator<Item = &'a str>) -> Result<HashSet<String>> {
let originals: HashSet<&str> = lemmas.into_iter().collect();
let annotated: Vec<String> = originals
.iter()
.filter_map(|word| flags_for(word).map(|flags| format!("{word}/{flags}")))
.collect();
if annotated.is_empty() {
return Ok(HashSet::new());
}
let word_list = format!("{}\n{}", annotated.len(), annotated.join("\n"));
let expanded = MutableDictionary::from_rune_files(&word_list, ATTRIBUTES)
.map_err(|e| anyhow::anyhow!("{e}"))
.context("expanding dictionary inflections")?;
Ok(expanded
.words_iter()
.map(|chars| chars.iter().collect::<String>())
.filter(|word| !originals.contains(word.as_str()))
.collect())
}
#[cfg(test)]
mod tests {
use super::{expand, flags_for};
fn forms(word: &str) -> Vec<String> {
let mut forms: Vec<String> = expand([word]).unwrap().into_iter().collect();
forms.sort();
forms
}
#[test]
fn a_noun_gets_its_plural() {
assert_eq!(forms("algebra"), ["algebras"]);
assert_eq!(forms("matrix"), ["matrixes"]);
assert_eq!(forms("category"), ["categories"]);
}
#[test]
fn a_verbal_ending_also_gets_past_and_progressive() {
assert_eq!(forms("quantize"), ["quantized", "quantizes", "quantizing"]);
}
#[test]
fn a_doubling_stem_is_given_no_past_or_progressive() {
assert_eq!(flags_for("occur"), Some("S"));
assert!(!forms("occur").contains(&"occured".to_string()));
}
#[test]
fn already_inflected_entries_are_left_alone() {
assert_eq!(flags_for("algebras"), None);
assert_eq!(flags_for("quotienting"), None);
assert_eq!(flags_for("formally"), None);
assert!(forms("algebras").is_empty());
}
#[test]
fn nouns_are_not_conjugated() {
assert_eq!(flags_for("quotient"), Some("S"));
}
#[test]
fn short_entries_and_symbols_are_skipped() {
assert_eq!(flags_for("fst"), None);
assert_eq!(flags_for("C++"), None);
assert_eq!(flags_for("Vec"), None);
}
#[test]
fn expansion_excludes_the_input() {
let out = expand(["algebra", "algebras"]).unwrap();
assert!(!out.contains("algebra"));
assert!(!out.contains("algebras"));
}
}