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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
This commit is contained in:
janis luksandClaude Sonnet 5.5 authored and Martin Ball committed 2026-11-01 22:34:32 +00:00
1 parent a4edc0dce7
commit 208de70f02
1 file changed
+38 -1
+38 -1
View File
@@ -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"));
}
}