From 425ae605ca9baa97e4b6177d2307c00a043c48fc Mon Sep 17 00:00:00 2001 From: janis luks <173142783+JanisLuks@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:52:11 +0000 Subject: [PATCH] Hunspell: no empty forms, and a circumfix suffix inside a chain needs its prefix Two more cases the forms() checks missed. - With FULLSTRIP a suffix can strip a stem down to nothing, so forms() could list an empty form that contains() never accepts. Empty forms are no longer listed, at the first suffix, in a chain or with a prefix. - A circumfix suffix at the start of a suffix chain was only checked for the outermost rule, so work/A, SFX A able/Bc, SFX B s was accepted as "workables" without the prefix. A circumfix suffix under another suffix is no longer a way to a word without its prefix, and forms() does not chain a form that ends in one. New tests cover both. Over the whole hunspell-lv lv_LV dictionary (66,480 stems, 4.5 million forms) every form forms() lists is still accepted by the checker. Ported from storytold/wordcraft#5012 (d655b3188) onto craft-proof. Adapted to flag ids; the form_has_flag circumfix filter already came in with the first port. Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2 --- crates/proof/src/affix.rs | 47 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index 8bab96c2..723fdd9e 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -485,6 +485,11 @@ fn cap_try(s: &str) -> String { out } +/// `form`, unless it is empty: no word is, and `FULLSTRIP` can strip a stem down to nothing. +fn non_empty(form: String) -> Option { + Some(form).filter(|f| !f.is_empty()) +} + /// `0` means empty in Hunspell's strip and append fields. fn zero_empty(s: &str) -> String { if s == "0" { String::new() } else { s.to_string() } @@ -1553,11 +1558,15 @@ impl AffixDict { continue; } let form = format!("{base}{}", r.add); + if form.is_empty() { + continue; + } let circ = self.cont_holds(r, self.circumfix); if r.cross { cross_sfx.push((form.clone(), circ)); } - if r.cont != 0 && queued.insert((form.clone(), r.cont)) { + // A circumfix suffix needs its prefix, so what it ends is never chained without one. + if r.cont != 0 && !circ && queued.insert((form.clone(), r.cont)) { chain.push((form.clone(), r.cont)); } // A circumfix suffix or one that needs another affix is no word on its own. @@ -1589,6 +1598,9 @@ impl AffixDict { continue; } let next = format!("{base}{}", r.add); + if next.is_empty() { + continue; + } if r.cont != 0 && queued.insert((next.clone(), r.cont)) { chain.push((next.clone(), r.cont)); } @@ -1613,7 +1625,7 @@ impl AffixDict { } let circ = self.cont_holds(r, self.circumfix); if !circ && !self.cont_holds(r, self.need_affix) { - out.push(format!("{}{rest}", r.add)); + out.extend(non_empty(format!("{}{rest}", r.add))); } if r.cross { // Every suffix form looked at counts against the work budget, matching or not. @@ -1626,7 +1638,7 @@ impl AffixDict { continue; } if let Some(rest) = s.strip_prefix(&*r.strip) { - out.push(format!("{}{rest}", r.add)); + out.extend(non_empty(format!("{}{rest}", r.add))); } } } @@ -3113,4 +3125,33 @@ x/12 assert_eq!(forms, ["x", "xy", "z"]); assert!(d.contains("z")); } + + /// `forms()` never lists an empty form, which `FULLSTRIP` can produce when a suffix replaces + /// the whole stem with nothing, at the first suffix or further along a chain. + #[test] + fn forms_never_list_an_empty_word() { + let aff = "SET UTF-8\nFULLSTRIP\nSFX A Y 2\nSFX A x 0 .\nSFX A 0 y/B .\nSFX B Y 1\nSFX B xy 0 .\n"; + let d = AffixDict::from_hunspell(aff, "1\nx/A\n", &[]).unwrap(); + let mut forms = d.forms("x"); + forms.sort(); + assert_eq!(forms, ["x", "xy"]); + assert!(!d.contains("")); + } + + /// A circumfix suffix in the middle of a suffix chain still needs its prefix: `workable` + /// carries the circumfix, so neither it nor `workables` is a word without `ne`. + #[test] + fn circumfix_suffix_inside_a_chain_still_needs_its_prefix() { + let aff = "SET UTF-8\nCIRCUMFIX c\nPFX P Y 1\nPFX P 0 ne/c .\nSFX A Y 1\nSFX A 0 able/Bc .\nSFX B Y 1\nSFX B 0 s .\n"; + let d = AffixDict::from_hunspell(aff, "1\nwork/AP\n", &[]).unwrap(); + let mut forms = d.forms("work"); + forms.sort(); + assert_eq!(forms, ["neworkable", "work"]); + for f in &forms { + assert!(d.contains(f), "{f}"); + } + for w in ["workable", "workables", "nework"] { + assert!(!d.contains(w), "{w}"); + } + } }