use std::sync::{Arc, LazyLock};
use harper_core::spell::{Dictionary as HarperDictionary, FstDictionary};
use crate::dictionary::Dictionary;
pub const HYPHENS: [char; 3] = ['-', '\u{2010}', '\u{2011}'];
const MIN_ROOT_CHARS: usize = 3;
const MIN_DERIVED_ROOT_CHARS: usize = 4;
const MAX_SUFFIX_STEPS: usize = 2;
static PREFIX_LIST: &str = include_str!("../dictionaries/affixes/prefixes.txt");
static PREFIXES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
let mut prefixes: Vec<&'static str> = PREFIX_LIST
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.collect();
prefixes.sort_unstable_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
prefixes
});
static CURATED: LazyLock<Arc<FstDictionary>> = LazyLock::new(FstDictionary::curated);
struct SuffixRule {
suffix: &'static str,
restores: &'static [&'static str],
}
const SUFFIX_RULES: &[SuffixRule] = &[
SuffixRule {
suffix: "ization",
restores: &["ize", "izes", ""],
},
SuffixRule {
suffix: "isation",
restores: &["ise", "ises", ""],
},
SuffixRule {
suffix: "ability",
restores: &["able", ""],
},
SuffixRule {
suffix: "ibility",
restores: &["ible"],
},
SuffixRule {
suffix: "izable",
restores: &["ize", ""],
},
SuffixRule {
suffix: "ivity",
restores: &["ive"],
},
SuffixRule {
suffix: "ical",
restores: &["y", "ic", ""],
},
SuffixRule {
suffix: "ally",
restores: &["", "al", "ic"],
},
SuffixRule {
suffix: "ness",
restores: &["", "e"],
},
SuffixRule {
suffix: "less",
restores: &[""],
},
SuffixRule {
suffix: "ship",
restores: &[""],
},
SuffixRule {
suffix: "hood",
restores: &[""],
},
SuffixRule {
suffix: "wise",
restores: &[""],
},
SuffixRule {
suffix: "able",
restores: &["", "e"],
},
SuffixRule {
suffix: "ible",
restores: &[""],
},
SuffixRule {
suffix: "ity",
restores: &["", "e"],
},
SuffixRule {
suffix: "ism",
restores: &["", "e"],
},
SuffixRule {
suffix: "ist",
restores: &["", "e"],
},
SuffixRule {
suffix: "ful",
restores: &[""],
},
SuffixRule {
suffix: "oid",
restores: &["", "e"],
},
SuffixRule {
suffix: "ify",
restores: &["", "y"],
},
SuffixRule {
suffix: "ize",
restores: &["", "e"],
},
SuffixRule {
suffix: "ise",
restores: &["", "e"],
},
SuffixRule {
suffix: "ic",
restores: &["", "y"],
},
SuffixRule {
suffix: "al",
restores: &["", "e"],
},
SuffixRule {
suffix: "ly",
restores: &["", "le"],
},
SuffixRule {
suffix: "or",
restores: &["", "e"],
},
SuffixRule {
suffix: "er",
restores: &["", "e"],
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AffixStep {
Prefix(&'static str),
Suffix(&'static str),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Analysis {
pub root: String,
pub steps: Vec<AffixStep>,
}
impl Analysis {
#[must_use]
pub fn describe(&self) -> String {
let mut out = String::new();
for step in &self.steps {
match step {
AffixStep::Prefix(prefix) => {
out.push_str(prefix);
out.push_str("-+");
}
AffixStep::Suffix(suffix) => {
out.push_str("+-");
out.push_str(suffix);
out.push('/');
}
}
}
out.push_str(&self.root);
out
}
}
#[derive(Debug, Clone)]
pub struct AffixAnalyzer {
english: bool,
}
impl AffixAnalyzer {
#[must_use]
pub fn new(language: &str) -> Self {
Self {
english: language.to_lowercase().starts_with("en"),
}
}
#[must_use]
pub fn analyze(&self, token: &str, dictionary: Option<&Dictionary>) -> Option<Analysis> {
if !self.english {
return None;
}
let lowered = token.to_lowercase();
if !lowered
.chars()
.all(|c| c.is_ascii_alphabetic() || HYPHENS.contains(&c))
{
return None;
}
let mut steps = Vec::new();
strip_prefix(&lowered, dictionary, &mut steps).or_else(|| {
steps.clear();
strip_suffixes(&lowered, dictionary, &mut steps)
})
}
}
fn strip_prefix(
word: &str,
dictionary: Option<&Dictionary>,
steps: &mut Vec<AffixStep>,
) -> Option<Analysis> {
for prefix in PREFIXES.iter() {
let Some(rest) = word.strip_prefix(prefix) else {
continue;
};
let residue = rest.strip_prefix(HYPHENS).unwrap_or(rest);
if residue.len() < MIN_ROOT_CHARS || residue.starts_with(HYPHENS) {
continue;
}
steps.push(AffixStep::Prefix(prefix));
if let Some(analysis) = strip_suffixes(residue, dictionary, steps) {
return Some(analysis);
}
steps.pop();
}
None
}
fn strip_suffixes(
word: &str,
dictionary: Option<&Dictionary>,
steps: &mut Vec<AffixStep>,
) -> Option<Analysis> {
let stripped_a_suffix = steps.iter().any(|s| matches!(s, AffixStep::Suffix(_)));
let floor = if stripped_a_suffix {
MIN_DERIVED_ROOT_CHARS
} else {
MIN_ROOT_CHARS
};
if !steps.is_empty() && word.chars().count() >= floor && is_known(word, dictionary) {
return Some(Analysis {
root: word.to_string(),
steps: steps.clone(),
});
}
if steps
.iter()
.filter(|s| matches!(s, AffixStep::Suffix(_)))
.count()
>= MAX_SUFFIX_STEPS
{
return None;
}
for rule in SUFFIX_RULES {
if steps.contains(&AffixStep::Suffix(rule.suffix)) {
continue;
}
let Some(stem) = word.strip_suffix(rule.suffix) else {
continue;
};
steps.push(AffixStep::Suffix(rule.suffix));
for restore in rule.restores {
let candidate = format!("{stem}{restore}");
if candidate.len() < MIN_ROOT_CHARS
|| !restoration_is_wellformed(rule.suffix, &candidate)
{
continue;
}
if let Some(analysis) = strip_suffixes(&candidate, dictionary, steps) {
return Some(analysis);
}
}
steps.pop();
}
None
}
fn restoration_is_wellformed(suffix: &str, candidate: &str) -> bool {
suffix != "able" || !(candidate.ends_with("ce") || candidate.ends_with("ge"))
}
fn is_known(word: &str, dictionary: Option<&Dictionary>) -> bool {
dictionary.is_some_and(|d| d.contains(word)) || CURATED.contains_word_str(word)
}
pub mod inflection;
#[cfg(test)]
mod tests;