use std::collections::HashMap;
use std::sync::LazyLock;
use super::pronunciation::cmudict::is_recorded_word;
static COMPOUNDS_RAW: &str = include_str!("../../../resources/english_compounds.txt");
const SEAM_DENYLIST: &[&str] = &["nightingale", "sheriff"];
const SUPPLEMENTAL: &[(&str, &[usize])] = &[
("twofold", &[3]), ("insofar", &[2, 4]), ("deshabille", &[3]), ("stalingrad", &[6]), ("viceregal", &[4]), ("motheaten", &[4]), ("newhaven", &[3]), ("sontheim", &[4]), ("sontheimer", &[4]),
("mishap", &[3]), ("chisholm", &[4]), ("kilowatt", &[4]), ("chifforobe", &[5]), ("moongod", &[4]), ("nongaseous", &[3]), ("pityard", &[4]), ("electroencephalogram", &[7]), ("disingenuous", &[3]), ("antitype", &[4]), ("cofounder", &[2]), ("filofax", &[4]), ("infrared", &[5]), ("prounion", &[3]), ("riboflavin", &[4]), ("styrofoam", &[5]), ("indiarubber", &[5]), ("forenoon", &[4]), ("doityourself", &[2, 4]), ("brailledocuments", &[7]), ];
const COMBINING_FORMS: &[&str] = &[
"micro", "macro", "mega", "nano", "mono", "bio", "geo", "neo", "photo", "electro", "thermo",
"hydro", "aero", "astro", "auto", "proto", "pseudo", "retro", "psycho", "socio", "ortho",
];
fn combining_form_seam(word: &str) -> Option<usize> {
COMBINING_FORMS.iter().find_map(|&form| {
let rest = word.strip_prefix(form)?;
(rest.len() >= 3 && is_recorded_word(rest)).then_some(form.len())
})
}
static SEAMS: LazyLock<HashMap<&'static str, Vec<usize>>> = LazyLock::new(|| {
let mut map: HashMap<&'static str, Vec<usize>> = HashMap::new();
for line in COMPOUNDS_RAW.lines() {
if let Some((word, seams)) = parse_compound_line(line) {
map.insert(word, seams);
}
}
for &bad in SEAM_DENYLIST {
map.remove(bad);
}
for &(word, seams) in SUPPLEMENTAL {
map.insert(word, seams.to_vec());
}
map
});
fn parse_compound_line(line: &'static str) -> Option<(&'static str, Vec<usize>)> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let (word, seams) = line.split_once('\t')?;
let seams: Vec<usize> = seams.split(',').filter_map(|s| s.parse().ok()).collect();
(!word.is_empty() && !seams.is_empty()).then_some((word, seams))
}
pub fn compound_seams(word: &str) -> Vec<usize> {
if let Some(seams) = SEAMS.get(word) {
return seams.clone();
}
combining_form_seam(word).into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[rstest::rstest]
#[case::anthill("anthill", 3)] #[case::carthorse("carthorse", 4)] #[case::foghorn("foghorn", 3)] #[case::sweetheart("sweetheart", 5)] fn known_compound_has_seam(#[case] word: &str, #[case] seam: usize) {
assert!(
compound_seams(word).contains(&seam),
"{word} should have seam {seam}, got {:?}",
compound_seams(word)
);
}
#[test]
fn compound_seams_clones_static_table_entry() {
assert_eq!(compound_seams(std::hint::black_box("anthill")), vec![3]);
}
#[rstest::rstest]
#[case::father("father")]
#[case::panther("panther")]
#[case::profile("profile")]
#[case::mother("mother")]
#[case::nightingale("nightingale")] #[case::sheriff("sheriff")] fn non_compound_has_no_seam(#[case] word: &str) {
assert!(
compound_seams(word).is_empty(),
"{word} must not be treated as a compound: {:?}",
compound_seams(word)
);
}
#[rstest::rstest]
#[case::twofold("twofold", 3)] #[case::insofar("insofar", 4)] fn supplemental_compound_has_seam(#[case] word: &str, #[case] seam: usize) {
assert!(
compound_seams(word).contains(&seam),
"{word} should have supplemental seam {seam}, got {:?}",
compound_seams(word)
);
}
#[rstest::rstest]
#[case::microfilm("microfilm", 5)] #[case::biofeedback("biofeedback", 3)] #[case::biofuel("biofuel", 3)] #[case::retrofit("retrofit", 5)] #[case::microwave("microwave", 5)] fn combining_form_seam_is_derived(#[case] word: &str, #[case] seam: usize) {
assert_eq!(
combining_form_seam(word),
Some(seam),
"{word} should derive seam {seam}"
);
}
#[rstest::rstest]
#[case::profile("profile")] #[case::confer("confer")] #[case::neon("neon")] fn combining_form_rule_does_not_overfire(#[case] word: &str) {
assert_eq!(
combining_form_seam(word),
None,
"{word} must not be split by the combining-form rule"
);
}
#[rstest::rstest]
#[case::blank("")]
#[case::comment("# comment")]
#[case::missing_tab("word 1,2")]
#[case::empty_word("\t1,2")]
#[case::empty_seams("word\tbad")]
fn parser_rejects_non_data_lines(#[case] line: &'static str) {
assert_eq!(parse_compound_line(line), None);
}
#[test]
fn parser_accepts_valid_data_line() {
assert_eq!(parse_compound_line("word\t1,3"), Some(("word", vec![1, 3])));
}
}