From 208de70f02b7ee801e087a701e3005ed4835c575 Mon Sep 17 00:00:00 2001 From: janis luks <173142783+JanisLuks@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:05:25 +0000 Subject: [PATCH] Hunspell: forms() chain loop follows NEEDAFFIX, CIRCUMFIX and FULLSTRIP The first-level suffix loop of forms() skipped suffix rules that need another affix or are circumfix ones, and rules that would replace a whole stem without FULLSTRIP. The chain loop did not, so forms() could list a chained form the checker rejects (work/A, SFX A able/B, SFX B s/N with NEEDAFFIX N listed workables). The chain loop now skips an empty base without FULLSTRIP and does not list a form whose last suffix needs another affix or is a circumfix one; such a form is still queued, so it can carry further suffixes. The new test checks that every listed form is accepted for the three cases. Ported from storytold/wordcraft#5012 (2f3a83f6e) onto craft-proof. Adapted to flag ids (cont_holds). 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 | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index a3b9b511..d7fa490f 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -1585,11 +1585,18 @@ impl AffixDict { continue; } let Some(base) = form.strip_suffix(&*r.strip) else { continue }; + if base.is_empty() && !self.full_strip { + continue; + } let next = format!("{base}{}", r.add); if r.cont != 0 && queued.insert((next.clone(), r.cont)) { chain.push((next.clone(), r.cont)); } - out.push(next); + // A circumfix suffix or one that needs another affix is no word on its own, + // but a form it ends can still carry further suffixes. + if !self.cont_holds(r, self.circumfix) && !self.cont_holds(r, self.need_affix) { + out.push(next); + } if out.len() >= MAX_FORMS { return out; } @@ -3072,4 +3079,34 @@ x/12 let back = AffixDict::from_bytes(&d.to_bytes().unwrap()).unwrap(); assert!(back.contains("nestromy") && !back.contains("stromy") && !back.contains("nestrom")); } + + /// A chained suffix form is listed only when the checker accepts it: not when its last suffix + /// needs another affix or is a circumfix one, and not when it would replace a whole stem + /// without `FULLSTRIP`. + #[test] + fn chained_forms_follow_needaffix_circumfix_and_fullstrip() { + let chain = |aff: &str, dic: &str, stem: &str| { + let d = AffixDict::from_hunspell(aff, dic, &[]).unwrap(); + let mut forms = d.forms(stem); + forms.sort(); + forms.dedup(); + for f in &forms { + assert!(d.contains(f), "{stem} → {f}"); + } + (d, forms) + }; + let (d, forms) = chain("SET UTF-8\nNEEDAFFIX N\nSFX A Y 1\nSFX A 0 able/B .\nSFX B Y 1\nSFX B 0 s/N .\n", "1\nwork/A\n", "work"); + assert_eq!(forms, ["work", "workable"]); + assert!(!d.contains("workables"), "its last suffix needs another affix"); + let (d, forms) = chain("SET UTF-8\nCIRCUMFIX c\nSFX A Y 1\nSFX A 0 able/B .\nSFX B Y 1\nSFX B 0 s/c .\n", "1\nwork/A\n", "work"); + assert_eq!(forms, ["work", "workable"]); + assert!(!d.contains("workables"), "its last suffix is a circumfix one"); + let aff = "SFX A Y 1\nSFX A 0 y/B .\nSFX B Y 1\nSFX B xy z .\n"; + let (d, forms) = chain(&format!("SET UTF-8\n{aff}"), "1\nx/A\n", "x"); + assert_eq!(forms, ["x", "xy"]); + assert!(!d.contains("z"), "a whole stem is replaced only with FULLSTRIP"); + let (d, forms) = chain(&format!("SET UTF-8\nFULLSTRIP\n{aff}"), "1\nx/A\n", "x"); + assert_eq!(forms, ["x", "xy", "z"]); + assert!(d.contains("z")); + } }