From a4edc0dce7f86742ea90dd4b2fa71f667413e87b Mon Sep 17 00:00:00 2001 From: janis luks <173142783+JanisLuks@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:34:42 +0000 Subject: [PATCH 1/9] Hunspell: support FULLSTRIP, NEEDAFFIX and CIRCUMFIX The affix engine refused any dictionary that uses these three directives, so a dictionary such as Latvian lv_LV (hunspell-lv, LGPL-2.1+) never loaded and its language showed "(dictionary unreadable)". - FULLSTRIP: an affix may strip, and so replace, a whole stem. - NEEDAFFIX: a stem carrying the flag is not a word on its own, and a rule with it among its continuation classes gives forms that need one more affix (a prefix satisfies it for a suffix and the other way). - CIRCUMFIX: a prefix or suffix carrying the flag is valid only together with one that carries it too; plain affixes never pair with circumfix ones. forms() follows the same rules, so every form it lists is still accepted by the checker, and the compact format keeps the three directives. Tests cover each directive, the forms invariant and the compact round trip. Ported from storytold/wordcraft#5012 (989e942bc) onto craft-proof. Adapted to craft-proof's flag ids (this branch sits on the FLAG num/long port, storytold/wordcraft#5142): NEEDAFFIX and CIRCUMFIX are flag ids read in the FLAG mode, continuation-class tests are set lookups, and NEEDAFFIX joins the lookup exclusions. The ROADMAP.md part and the Latvian language module (c99142ae9) are WordCraft's, not ported. 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 | 206 ++++++++++++++++++++++++++++++-------- 1 file changed, 163 insertions(+), 43 deletions(-) diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index b742f995..a3b9b511 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -13,18 +13,19 @@ //! //! Supported: one-character flags (any Unicode character — `FLAG UTF-8`), two-character flags //! (`FLAG long`) and numeric flags (`FLAG num`: comma-separated numbers from 0 to 65,535), up to -//! 65,536 distinct flags in all ([`FlagMode`]), flag aliases (`AF`: a number standing for a set of flags), `PFX`/`SFX` with strip, append and -//! condition (`.`, letters, `[abc]`, `[^abc]`), cross products, continuation classes on suffixes -//! (at most two suffixes in a row, as in Hunspell), `FORBIDDENWORD`, `NOSUGGEST`, `KEEPCASE` (the -//! stem is a word only in its own letter case), input conversion (`ICONV`: ligatures such as `fi` -//! read as `fi`) and ignored characters (`IGNORE`), and the suggestion tables `TRY`, `KEY`, `MAP` -//! and `REP`. Compounds by `COMPOUNDRULE` (patterns of flags with `*` and `?`, as SCOWL's English +//! 65,536 distinct flags in all ([`FlagMode`]), flag aliases (`AF`: a number standing for a set of +//! flags), `PFX`/`SFX` with strip, append and condition (`.`, letters, `[abc]`, `[^abc]`), cross +//! products, continuation classes on suffixes (at most two suffixes in a row, as in Hunspell), +//! `FORBIDDENWORD`, `NOSUGGEST`, `KEEPCASE` (the stem is a word only in its own letter case), +//! `NEEDAFFIX`, `CIRCUMFIX`, `FULLSTRIP`, input conversion (`ICONV`: ligatures such as `fi` read as +//! `fi`) and ignored characters (`IGNORE`), and the suggestion tables `TRY`, `KEY`, `MAP` and `REP`. +//! Compounds by `COMPOUNDRULE` (patterns of flags with `*` and `?`, as SCOWL's English //! dictionaries use for ordinals: `21st`, `112th`; with long or numeric flags each flag is written //! in parentheses, `(aa)*(bb)`), with `ONLYINCOMPOUND` and `COMPOUNDMIN`; the parts of such a //! compound are stems, without affixes, as in Hunspell. //! Morphological data (`AM` aliases, the fields after a stem) is skipped. Rejected: flag-based -//! compounding (`COMPOUNDFLAG`, `COMPOUNDBEGIN`…), `NEEDAFFIX` and the other directives that -//! change which words are correct. +//! compounding (`COMPOUNDFLAG`, `COMPOUNDBEGIN`…) and the other directives that change which +//! words are correct. //! //! Each distinct flag gets a dense `u16` id; a stem's flags and a rule's continuation classes are //! a sorted set of ids, interned once in a flat table, so membership is a binary search in a few @@ -68,7 +69,6 @@ const MAX_DISTINCT_FLAGS: usize = 1 << 16; /// Directives that change which words are correct in ways this engine doesn't implement. const UNSUPPORTED: &[&str] = &[ "CHECKSHARPS", - "CIRCUMFIX", "COMPLEXPREFIXES", "COMPOUNDBEGIN", "COMPOUNDEND", @@ -77,8 +77,6 @@ const UNSUPPORTED: &[&str] = &[ "COMPOUNDLAST", "COMPOUNDMIDDLE", "COMPOUNDPERMITFLAG", - "FULLSTRIP", - "NEEDAFFIX", "OCONV", "PSEUDOROOT", ]; @@ -291,6 +289,14 @@ pub struct AffixDict { rep: Vec<(String, String)>, /// The `KEEPCASE` flag: such stems are words only in their own letter case. keepcase: Option, + /// `NEEDAFFIX`: a stem carrying it is not a word on its own, and a rule carrying it in its + /// continuation classes gives forms that need one more affix. + need_affix: Option, + /// `CIRCUMFIX`: a prefix or suffix carrying it in its continuation classes is only valid + /// together with a suffix or prefix that carries it too. + circumfix: Option, + /// `FULLSTRIP`: an affix may strip (and so replace) a whole stem. + full_strip: bool, /// `ICONV`: input conversions applied before a lookup, `(from, to)`. iconv: Vec<(String, String)>, /// `IGNORE`: characters removed from input words (and from the stems and appended texts). @@ -379,6 +385,9 @@ struct Rules { substandard: Option, /// The `KEEPCASE` flag. keepcase: Option, + need_affix: Option, + circumfix: Option, + full_strip: bool, iconv: Vec<(String, String)>, ignore: Vec, /// `COMPOUNDRULE` patterns, as written (`n*1t`); read in the `FLAG` mode by @@ -621,9 +630,10 @@ impl FlagMode { } /// Stem flags that rule a lookup out: `FORBIDDENWORD` always, and per lookup `NOSUGGEST` and -/// `SUBSTANDARD` (suggestions) or `KEEPCASE` (case variants of a word). +/// `SUBSTANDARD` (suggestions), `KEEPCASE` (case variants of a word) or `NEEDAFFIX` (a stem on +/// its own). #[derive(Clone, Copy, Debug, Default)] -struct Excluded([Option; 4]); +struct Excluded([Option; 5]); impl Excluded { /// Does the flag set `set` carry an excluded flag? @@ -756,6 +766,9 @@ fn parse_aff(aff: &str, english: bool) -> Result { let min = at(1).parse::().map_err(|_| format!("line {}: COMPOUNDMIN needs a number", n + 1))?; r.compound_min = Some(min.clamp(1, MAX_COMPOUND_BYTES)); } + "NEEDAFFIX" => r.need_affix = Some(r.one_flag(at(1)).map_err(|e| format!("line {}: {dir} needs one flag: {e}", n + 1))?), + "CIRCUMFIX" => r.circumfix = Some(r.one_flag(at(1)).map_err(|e| format!("line {}: {dir} needs one flag: {e}", n + 1))?), + "FULLSTRIP" => r.full_strip = true, "FLAG" => r.set_mode(at(1)).map_err(|e| format!("line {}: {e}", n + 1))?, "FORBIDDENWORD" => r.forbidden = Some(r.one_flag(at(1)).map_err(|e| format!("line {}: {dir} needs one flag: {e}", n + 1))?), "NOSUGGEST" => r.no_suggest = Some(r.one_flag(at(1)).map_err(|e| format!("line {}: {dir} needs one flag: {e}", n + 1))?), @@ -939,7 +952,10 @@ impl AffixDict { let mut distinct: FxMap = FxMap::default(); let all = rules.prefixes.iter().chain(&rules.suffixes).map(|r| r.0); let all = all.chain(pfx_cont.iter().chain(&sfx_cont).flatten().copied()).chain(stem_keys.iter().copied()); - for k in all.chain(rules.forbidden).chain(rules.no_suggest).chain(rules.substandard).chain(rules.keepcase).chain(rules.only_in_compound) { + // NEEDAFFIX and CIRCUMFIX flags may only ever appear in rules' continuation classes. + let directives = + [rules.forbidden, rules.no_suggest, rules.substandard, rules.keepcase, rules.only_in_compound, rules.need_affix, rules.circumfix]; + for k in all.chain(directives.into_iter().flatten()) { distinct.entry(k).or_insert(0); if distinct.len() > MAX_DISTINCT_FLAGS { return Err(format!("more than {MAX_DISTINCT_FLAGS} distinct flags")); @@ -964,6 +980,9 @@ impl AffixDict { no_suggest: rules.no_suggest.and_then(id_of), substandard: rules.substandard.and_then(id_of), keepcase: rules.keepcase.and_then(id_of), + need_affix: rules.need_affix.and_then(id_of), + circumfix: rules.circumfix.and_then(id_of), + full_strip: rules.full_strip, flag_mode: mode, iconv: rules.iconv, ignore: rules.ignore.into_boxed_slice(), @@ -1254,6 +1273,11 @@ impl AffixDict { None } + /// Do the continuation classes of `r` hold `flag`? + fn cont_holds(&self, r: &Rule, flag: Option) -> bool { + flag.is_some_and(|f| has(self.set(r.cont), f)) + } + /// Can a derived form carry `flag` (does some rule's continuation classes hold it)? fn is_cont_flag(&self, flag: u16) -> bool { self.cont_flag.get(usize::from(flag)).copied().unwrap_or(false) @@ -1280,7 +1304,7 @@ impl AffixDict { // total size of the distinct sets it meets, not rules × set size. let probe = FlagProbe::new(flags); let mut seen: FxMap = FxMap::default(); - let cuts = std::iter::once(w.len()).chain(w.char_indices().rev().map(|(i, _)| i).filter(|&i| i > 0)); + let cuts = std::iter::once(w.len()).chain(w.char_indices().rev().map(|(i, _)| i).filter(|&i| i > 0 || self.full_strip)); for b in cuts.take(self.max_sfx_chars + 1) { let (Some(base), Some(add)) = (w.get(..b), w.get(b..)) else { continue }; let Some(groups) = self.sfx_index.get(add) else { continue }; @@ -1291,7 +1315,7 @@ impl AffixDict { inner.clear(); for r in g.cont_rules.iter().filter_map(|&i| self.suffixes.get(i as usize)) { let meets = *seen.entry(r.cont).or_insert_with(|| probe.meets(self.set(r.cont))); - if meets && r.cond_matches(&root, true) { + if meets && !self.cont_holds(r, self.circumfix) && r.cond_matches(&root, true) { inner.push(r.flag); } } @@ -1353,13 +1377,14 @@ impl AffixDict { if w.is_empty() || w.len() > MAX_WORD_BYTES { return false; } - if self.stem_flags_except(w, excluded).is_some() || self.suffixed(w, None, excluded) { + // A stem that needs an affix is no word on its own. + if self.stem_flags_except(w, excluded.and(self.need_affix)).is_some() || self.suffixed(w, None, false, excluded) { return true; } let mut root = String::with_capacity(w.len() + 8); for r in &self.prefixes { let Some(rest) = w.strip_prefix(&*r.add) else { continue }; - if rest.is_empty() { + if rest.is_empty() && !self.full_strip { continue; } root.clear(); @@ -1368,13 +1393,18 @@ impl AffixDict { if !r.cond_matches(&root, false) { continue; } - if self.stem_flags_except(&root, excluded).is_some_and(|f| has(f, r.flag)) { - return true; + // A circumfix prefix works only with its suffix, and one that needs another affix + // only with a suffix too. + let circ = self.cont_holds(r, self.circumfix); + if !circ && !self.cont_holds(r, self.need_affix) { + if self.stem_flags_except(&root, excluded).is_some_and(|f| has(f, r.flag)) { + return true; + } + if self.is_cont_flag(r.flag) && self.form_has_flag(&root, &[r.flag], INNER_CHAIN, excluded) { + return true; + } } - if self.is_cont_flag(r.flag) && self.form_has_flag(&root, &[r.flag], INNER_CHAIN, excluded) { - return true; - } - if r.cross && self.suffixed(&root, Some(r.flag), excluded) { + if r.cross && self.suffixed(&root, Some(r.flag), circ, excluded) { return true; } } @@ -1417,13 +1447,16 @@ impl AffixDict { } /// Is `w` a stem with one suffix? With `need` set (a prefix was taken off), the stem must - /// carry that flag too and the suffix must allow cross products. - fn suffixed(&self, w: &str, need: Option, excluded: Excluded) -> bool { + /// carry that flag too and the suffix must allow cross products. `circ` says whether that + /// prefix is a circumfix one: the suffix must be one too, and otherwise must not be. + fn suffixed(&self, w: &str, need: Option, circ: bool, excluded: Excluded) -> bool { + // Fits the suffix rule `r`: circumfix alike, and not in need of another affix unless a prefix is there. + let fits = |r: &Rule| self.cont_holds(r, self.circumfix) == circ && (need.is_some() || !self.cont_holds(r, self.need_affix)); let mut root = String::with_capacity(w.len() + 8); let mut flags: Vec = Vec::new(); // Each split point from the end: the appended string is `w[b..]`, at most the longest // suffix, and something must stay before it. - let cuts = std::iter::once(w.len()).chain(w.char_indices().rev().map(|(i, _)| i).filter(|&i| i > 0)); + let cuts = std::iter::once(w.len()).chain(w.char_indices().rev().map(|(i, _)| i).filter(|&i| i > 0 || self.full_strip)); for b in cuts.take(self.max_sfx_chars + 1) { let (Some(base), Some(add)) = (w.get(..b), w.get(b..)) else { continue }; let Some(groups) = self.sfx_index.get(add) else { continue }; @@ -1436,7 +1469,7 @@ impl AffixDict { // when a cross product is being checked). if let Some(f) = self.stem_flags_except(&root, excluded) && need.is_none_or(|n| has(f, n)) - && rules().any(|r| has(f, r.flag) && (need.is_none() || r.cross) && r.cond_matches(&root, true)) + && rules().any(|r| has(f, r.flag) && (need.is_none() || r.cross) && fits(r) && r.cond_matches(&root, true)) { return true; } @@ -1447,7 +1480,7 @@ impl AffixDict { if need.is_none() && self.has_cont { flags.clear(); // Only a flag some continuation class holds can come from another rule. - flags.extend(rules().filter(|r| self.is_cont_flag(r.flag) && r.cond_matches(&root, true)).map(|r| r.flag)); + flags.extend(rules().filter(|r| self.is_cont_flag(r.flag) && fits(r) && r.cond_matches(&root, true)).map(|r| r.flag)); flags.sort_unstable(); flags.dedup(); if !flags.is_empty() && self.form_has_flag(&root, &flags, INNER_CHAIN, excluded) { @@ -1500,8 +1533,12 @@ impl AffixDict { /// cost millions of strings (#3613). pub fn forms(&self, stem: &str) -> Vec { let Some(f) = self.stem_flags(stem) else { return Vec::new() }; - let mut out = vec![stem.to_string()]; - let mut cross_sfx = Vec::new(); + let mut out = Vec::new(); + if !self.need_affix.is_some_and(|n| has(f, n)) { + out.push(stem.to_string()); + } + // Suffix forms a prefix may be added to, with whether they are circumfix ones. + let mut cross_sfx: Vec<(String, bool)> = Vec::new(); // Suffix forms queued with the continuation classes they carry, for chained expansion; // each (form, classes) pair is expanded once. let mut chain: Vec<(String, u32)> = Vec::new(); @@ -1512,14 +1549,21 @@ impl AffixDict { continue; } let Some(base) = stem.strip_suffix(&*r.strip) else { continue }; + if base.is_empty() && !self.full_strip { + continue; + } let form = format!("{base}{}", r.add); + let circ = self.cont_holds(r, self.circumfix); if r.cross { - cross_sfx.push(form.clone()); + cross_sfx.push((form.clone(), circ)); } if r.cont != 0 && queued.insert((form.clone(), r.cont)) { chain.push((form.clone(), r.cont)); } - out.push(form); + // A circumfix suffix or one that needs another affix is no word on its own. + if !circ && !self.cont_holds(r, self.need_affix) { + out.push(form); + } if out.len() >= MAX_FORMS { return out; } @@ -1557,9 +1601,15 @@ impl AffixDict { continue; } let Some(rest) = stem.strip_prefix(&*r.strip) else { continue }; - out.push(format!("{}{rest}", r.add)); + if rest.is_empty() && !self.full_strip { + continue; + } + let circ = self.cont_holds(r, self.circumfix); + if !circ && !self.cont_holds(r, self.need_affix) { + out.push(format!("{}{rest}", r.add)); + } if r.cross { - for s in &cross_sfx { + for (s, _) in cross_sfx.iter().filter(|(_, c)| *c == circ) { work += 1; if out.len() >= MAX_FORMS || work > MAX_FORMS_WORK { return out; @@ -1654,9 +1704,17 @@ impl AffixDict { self.flags.iter().any(|&id| self.set(id).contains(&flag)) || self.prefixes.iter().chain(&self.suffixes).any(|r| r.flag == flag || self.set(r.cont).contains(&flag)) }; - for (directive, flag) in - [("FORBIDDENWORD", self.forbidden), ("NOSUGGEST", self.no_suggest), ("SUBSTANDARD", self.substandard), ("KEEPCASE", self.keepcase)] - { + if self.full_strip { + out.push_str("FULLSTRIP\n"); + } + for (directive, flag) in [ + ("FORBIDDENWORD", self.forbidden), + ("NOSUGGEST", self.no_suggest), + ("SUBSTANDARD", self.substandard), + ("KEEPCASE", self.keepcase), + ("NEEDAFFIX", self.need_affix), + ("CIRCUMFIX", self.circumfix), + ] { if let Some(flag) = flag.filter(|&f| self.flag_mode != FlagMode::Char || used(f)) { out.push_str(directive); out.push(' '); @@ -1754,14 +1812,17 @@ impl AffixDict { "FLAG" => rules.set_mode(at(1)).map_err(|e| format!("affix dictionary: {e}"))?, "TRY" => rules.try_chars = cap_try(at(1)), "KEY" => rules.key = at(1).to_string(), - directive @ ("FORBIDDENWORD" | "NOSUGGEST" | "SUBSTANDARD" | "KEEPCASE" | "ONLYINCOMPOUND") => { + "FULLSTRIP" => rules.full_strip = true, + directive @ ("FORBIDDENWORD" | "NOSUGGEST" | "SUBSTANDARD" | "KEEPCASE" | "ONLYINCOMPOUND" | "NEEDAFFIX" | "CIRCUMFIX") => { let c = rules.one_flag(at(1)).map_err(|e| format!("affix dictionary: bad {directive} in `{line}`: {e}"))?; match directive { "FORBIDDENWORD" => rules.forbidden = Some(c), "NOSUGGEST" => rules.no_suggest = Some(c), "SUBSTANDARD" => rules.substandard = Some(c), + "KEEPCASE" => rules.keepcase = Some(c), "ONLYINCOMPOUND" => rules.only_in_compound = Some(c), - _ => rules.keepcase = Some(c), + "NEEDAFFIX" => rules.need_affix = Some(c), + _ => rules.circumfix = Some(c), } } "ICONV" if f.len() == 3 => rules.iconv.push((at(1).to_string(), at(2).to_string())), @@ -2480,9 +2541,7 @@ pub(crate) mod tests { assert!(d.check("1st") && d.check("11th") && d.check("111th") && !d.check("11st"), "ordinals by COMPOUNDRULE"); assert!(!d.check("housed") && !d.check("hous")); // The directives that change which words are right are still refused. - for dir in ["NEEDAFFIX", "CIRCUMFIX", "PSEUDOROOT", "FULLSTRIP"] { - assert!(AffixDict::from_hunspell_english(&format!("{dir} X\n"), dic, &[]).is_err(), "{dir}"); - } + assert!(AffixDict::from_hunspell_english("PSEUDOROOT X\n", dic, &[]).is_err(), "PSEUDOROOT"); } /// The English path reads `ONLYINCOMPOUND` in the dictionary's FLAG mode too. @@ -2952,4 +3011,65 @@ x/12 } } } + /// `FULLSTRIP` lets an affix replace a whole stem; without it a stem must outlive its strip. + #[test] + fn fullstrip_allows_an_affix_to_replace_the_whole_stem() { + let aff = "SET UTF-8\nSFX A Y 1\nSFX A ab x .\nPFX B Y 1\nPFX B ab y .\n"; + let dic = "1\nab/AB\n"; + let without = AffixDict::from_hunspell(aff, dic, &[]).unwrap(); + for w in ["ab", "x", "y"] { + assert_eq!(without.contains(w), w == "ab", "{w} without FULLSTRIP"); + } + let with = AffixDict::from_hunspell(&format!("FULLSTRIP\n{aff}"), dic, &[]).unwrap(); + for w in ["ab", "x", "y"] { + assert!(with.contains(w), "{w} with FULLSTRIP"); + } + assert!(with.forms("ab").iter().all(|f| with.contains(f))); + assert!(!without.forms("ab").iter().any(|f| f == "x" || f == "y")); + let back = AffixDict::from_bytes(&with.to_bytes().unwrap()).unwrap(); + assert!(back.contains("x") && back.contains("y")); + } + + /// `NEEDAFFIX`: a stem or a suffix form that needs another affix is not a word without one. + #[test] + fn needaffix_stems_and_forms_need_one_more_affix() { + let aff = "SET UTF-8\nNEEDAFFIX N\nSFX A Y 1\nSFX A 0 s .\nSFX B Y 1\nSFX B 0 x/AN .\nPFX P Y 1\nPFX P 0 re .\n"; + let d = AffixDict::from_hunspell(aff, "3\nfoo/NA\nbar/AB\nbaz/NP\n", &[]).unwrap(); + for w in ["foos", "bar", "bars", "barxs", "rebaz"] { + assert!(d.contains(w), "{w}"); + } + assert!(!d.contains("foo"), "a stem that needs an affix"); + assert!(!d.contains("baz"), "a stem that needs an affix"); + // `barx` carries NEEDAFFIX through its continuation class: it needs `s` after it. + assert!(!d.contains("barx"), "a form that needs another affix"); + for stem in d.stems().map(str::to_string).collect::>() { + for f in d.forms(&stem) { + assert!(d.contains(&f), "{stem} → {f}"); + } + } + assert!(!d.forms("foo").iter().any(|f| f == "foo") && d.forms("foo").iter().any(|f| f == "foos")); + let back = AffixDict::from_bytes(&d.to_bytes().unwrap()).unwrap(); + assert!(!back.contains("foo") && back.contains("foos")); + } + + /// `CIRCUMFIX`: a circumfix prefix and suffix only work as a pair. + #[test] + fn circumfix_prefix_and_suffix_only_work_together() { + let aff = "SET UTF-8\nCIRCUMFIX c\nPFX P Y 1\nPFX P 0 ne/c .\nSFX S Y 1\nSFX S 0 y/c .\nSFX T Y 1\nSFX T 0 z .\n"; + let d = AffixDict::from_hunspell(aff, "1\nstrom/PST\n", &[]).unwrap(); + for w in ["strom", "stromz", "nestromy"] { + assert!(d.contains(w), "{w}"); + } + for w in ["stromy", "nestrom", "nestromz"] { + assert!(!d.contains(w), "{w}"); + } + for f in d.forms("strom") { + assert!(d.contains(&f), "strom → {f}"); + } + let mut forms = d.forms("strom"); + forms.sort(); + assert_eq!(forms, ["nestromy", "strom", "stromz"]); + let back = AffixDict::from_bytes(&d.to_bytes().unwrap()).unwrap(); + assert!(back.contains("nestromy") && !back.contains("stromy") && !back.contains("nestrom")); + } } 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 2/9] 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")); + } } From 2bf2285577f8e52e9473277197282507d83062bd Mon Sep 17 00:00:00 2001 From: janis luks <173142783+JanisLuks@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:15:24 +0000 Subject: [PATCH 3/9] Hunspell: count every cross-product suffix scanned against the forms() budget The circumfix filter on the prefix cross-product loop of forms() only counted work for suffix forms that matched the circumfix class of the prefix. A dictionary with many circumfix prefixes and thousands of ordinary cross-product suffixes could scan the incompatible ones without ever reaching MAX_FORMS_WORK. The loop now counts each suffix form it looks at, then skips the ones of the wrong class, so the budget bounds the scan as it did before the filter was added. Ported from storytold/wordcraft#5012 (bc947e733) onto craft-proof. 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 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index d7fa490f..8bab96c2 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -1616,11 +1616,15 @@ impl AffixDict { out.push(format!("{}{rest}", r.add)); } if r.cross { - for (s, _) in cross_sfx.iter().filter(|(_, c)| *c == circ) { + // Every suffix form looked at counts against the work budget, matching or not. + for (s, s_circ) in &cross_sfx { work += 1; if out.len() >= MAX_FORMS || work > MAX_FORMS_WORK { return out; } + if *s_circ != circ { + continue; + } if let Some(rest) = s.strip_prefix(&*r.strip) { out.push(format!("{}{rest}", r.add)); } 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 4/9] 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}"); + } + } } From 42680a7e2195bf47b01d0f34f3047991b56ca863 Mon Sep 17 00:00:00 2001 From: janis luks <173142783+JanisLuks@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:13:41 +0000 Subject: [PATCH 5/9] Hunspell: an empty suffix form still feeds chains and cross products The previous commit skipped an empty suffix form altogether, so forms() no longer listed what a continuation class or a prefix adds to it, although contains() accepts it: with FULLSTRIP, x/A, SFX A x 0/B and SFX B 0 y, contains("y") is true but forms("x") left "y" out. An empty form is now only kept out of the list. It still goes into the cross-product list and the chain queue, as before the previous commit. The new test covers the chain case and checks that every form listed for a prefix over an empty suffix form is accepted. Ported from storytold/wordcraft#5012 (6fa95a695) onto craft-proof. Adapted to flag ids. 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 | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index 723fdd9e..c410ce3d 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -1558,9 +1558,6 @@ 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)); @@ -1570,8 +1567,9 @@ impl AffixDict { chain.push((form.clone(), r.cont)); } // A circumfix suffix or one that needs another affix is no word on its own. + // (An empty form still feeds the cross products and chains above; it is no word.) if !circ && !self.cont_holds(r, self.need_affix) { - out.push(form); + out.extend(non_empty(form)); } if out.len() >= MAX_FORMS { return out; @@ -1598,16 +1596,13 @@ 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)); } // 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); + out.extend(non_empty(next)); } if out.len() >= MAX_FORMS { return out; @@ -3154,4 +3149,21 @@ x/12 assert!(!d.contains(w), "{w}"); } } + + /// An empty form (`FULLSTRIP` stripping a whole stem to nothing) is no word itself, but what + /// a continuation class or a prefix adds to it is: `x` + `SFX A x 0/B`, `SFX B 0 y` makes `y`. + #[test] + fn an_empty_suffix_form_still_feeds_chains_and_prefixes() { + let aff = "SET UTF-8\nFULLSTRIP\nSFX A Y 1\nSFX A x 0/B .\nSFX B Y 1\nSFX B 0 y .\n"; + let d = AffixDict::from_hunspell(aff, "1\nx/A\n", &[]).unwrap(); + let mut forms = d.forms("x"); + forms.sort(); + assert_eq!(forms, ["x", "y"]); + assert!(d.contains("y") && !d.contains("")); + let aff = "SET UTF-8\nFULLSTRIP\nSFX A Y 1\nSFX A x 0 .\nPFX P Y 1\nPFX P 0 q .\n"; + let d = AffixDict::from_hunspell(aff, "1\nx/AP\n", &[]).unwrap(); + for f in d.forms("x") { + assert!(!f.is_empty() && d.contains(&f), "x → {f:?}"); + } + } } From d7bdd858bdb70f768bef6a02cc25266cb79feb00 Mon Sep 17 00:00:00 2001 From: janis luks <173142783+JanisLuks@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:44:34 +0000 Subject: [PATCH 6/9] Hunspell: forms() checks a prefix condition on what the prefix is added to forms() tested a prefix rule's condition on the stem only, then added the prefix to every cross-product suffix form as well. contains() tests the condition on what the prefix comes off: the stem for the prefix alone, the suffix form in a cross product. So forms() could list a cross product the checker rejects (q over an empty suffix form under the condition x) and miss one it accepts. A small helper, prefixed(), now adds a prefix the way the checker takes it back off, for the stem and for each cross-product suffix form. The tests cover a cross product that meets the condition and one that does not. Over the whole hunspell-lv lv_LV dictionary the listed forms are unchanged (4,515,511, all accepted). Ported from storytold/wordcraft#5012 (8d84a49f5) onto craft-proof. Adapted to flag ids. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2 --- crates/proof/src/affix.rs | 46 +++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index c410ce3d..6dea3f6e 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -1611,16 +1611,12 @@ impl AffixDict { } } for r in &self.prefixes { - if !has(f, r.flag) || !r.cond_matches(stem, false) { - continue; - } - let Some(rest) = stem.strip_prefix(&*r.strip) else { continue }; - if rest.is_empty() && !self.full_strip { + if !has(f, r.flag) { continue; } let circ = self.cont_holds(r, self.circumfix); if !circ && !self.cont_holds(r, self.need_affix) { - out.extend(non_empty(format!("{}{rest}", r.add))); + out.extend(self.prefixed(r, stem)); } if r.cross { // Every suffix form looked at counts against the work budget, matching or not. @@ -1632,9 +1628,7 @@ impl AffixDict { if *s_circ != circ { continue; } - if let Some(rest) = s.strip_prefix(&*r.strip) { - out.extend(non_empty(format!("{}{rest}", r.add))); - } + out.extend(self.prefixed(r, s)); } } if out.len() >= MAX_FORMS { @@ -1644,6 +1638,17 @@ impl AffixDict { out } + /// `root` with the prefix rule `r` added, when the checker takes it back off: the rule's + /// condition holds for `root` (the stem, or a suffix form in a cross product), and something + /// stays after the strip unless `FULLSTRIP`. + fn prefixed(&self, r: &Rule, root: &str) -> Option { + let rest = root.strip_prefix(&*r.strip)?; + if (rest.is_empty() && !self.full_strip) || !r.cond_matches(root, false) { + return None; + } + non_empty(format!("{}{rest}", r.add)) + } + /// The suffix rules carrying any flag of the set `set`, in rule order. fn suffixes_with(&self, set: u32) -> Vec { let mut rules: Vec = Vec::new(); @@ -3160,10 +3165,27 @@ x/12 forms.sort(); assert_eq!(forms, ["x", "y"]); assert!(d.contains("y") && !d.contains("")); - let aff = "SET UTF-8\nFULLSTRIP\nSFX A Y 1\nSFX A x 0 .\nPFX P Y 1\nPFX P 0 q .\n"; + let aff = "SET UTF-8\nFULLSTRIP\nSFX A Y 1\nSFX A x 0 .\nPFX P Y 1\nPFX P 0 q x\n"; let d = AffixDict::from_hunspell(aff, "1\nx/AP\n", &[]).unwrap(); - for f in d.forms("x") { - assert!(!f.is_empty() && d.contains(&f), "x → {f:?}"); + let mut forms = d.forms("x"); + forms.sort(); + assert_eq!(forms, ["qx", "x"], "`q` over the empty form fails the condition `x`"); + assert!(d.contains("qx") && !d.contains("q")); + } + + /// A prefix condition is checked on what the prefix is added to: the stem for the prefix + /// alone, the suffix form in a cross product, as `contains()` does. Under the condition `ab`, + /// `ac` + `-c+b` = `ab` takes `z`, and `ab` + `-b+c` = `ac` does not. + #[test] + fn prefix_conditions_apply_to_the_suffix_form_in_cross_products() { + let aff = "SET UTF-8\nPFX P Y 1\nPFX P 0 z ab\nSFX A Y 2\nSFX A c b c\nSFX A b c b\n"; + let d = AffixDict::from_hunspell(aff, "2\nac/AP\nab/AP\n", &[]).unwrap(); + for stem in ["ac", "ab"] { + for f in d.forms(stem) { + assert!(d.contains(&f), "{stem} → {f}"); + } } + assert!(d.forms("ac").iter().any(|f| f == "zab"), "the cross product meets the condition"); + assert!(!d.forms("ab").iter().any(|f| f == "zac") && !d.contains("zac"), "the cross product fails it"); } } From 2c2dd12417311ae5345202052973fb839567fe2e Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Sun, 1 Nov 2026 18:26:48 +0000 Subject: [PATCH 7/9] =?UTF-8?q?CP6.3:=20property=20test=20=E2=80=94=20FULL?= =?UTF-8?q?STRIP,=20NEEDAFFIX=20and=20CIRCUMFIX=20never=20panic=20and=20fo?= =?UTF-8?q?rms()=20stays=20checkable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Random stems and words through a dictionary that uses all three directives (whole-stem strips, empty forms, circumfix pairs, forms that need another affix): no panic, and every form forms() lists is non-empty and accepted by the checker. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2 --- crates/proof/src/affix.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index 6dea3f6e..bde194a4 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -3007,6 +3007,21 @@ pub(crate) mod tests { let _ = AffixDict::from_bytes(&miniz_oxide::deflate::compress_to_vec(&bytes, 1)); } + /// FULLSTRIP, NEEDAFFIX and CIRCUMFIX on hostile stems and words: no panic, and every + /// form `forms()` lists is one the checker accepts. + #[test] + fn needaffix_circumfix_and_fullstrip_never_panic(stem in "[abx]{0,4}", word in "\\PC{0,12}") { + let aff = "SET UTF-8\nFULLSTRIP\nNEEDAFFIX N\nCIRCUMFIX C\nPFX P Y 2\nPFX P a 0/C .\nPFX P 0 x/N a\nSFX S Y 3\nSFX S b 0/C b\nSFX S 0 x/NT .\nSFX S ab y .\nSFX T Y 1\nSFX T x 0 .\n"; + if let Ok(d) = AffixDict::from_hunspell(aff, &format!("2\n{stem}/PSN\n{stem}b/PS\n"), &[]) { + let _ = d.check(&word); + for s in [stem.clone(), format!("{stem}b")] { + for f in d.forms(&s) { + proptest::prop_assert!(!f.is_empty() && d.contains(&f), "{s} → {f:?}"); + } + } + } + } + #[test] fn any_flag_field_reads_without_panicking(field in "[0-9,a-zA-Z!\u{fe0f}\u{1f6ab}]{0,24}", word in "\\PC{0,12}") { for mode in ["num", "long", "UTF-8"] { From fdcebf09f88c650c8879784ba00904c6e8fe0d23 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Sun, 1 Nov 2026 20:27:46 +0000 Subject: [PATCH 8/9] CP6.8: a NEEDAFFIX stem is no part of a COMPOUNDRULE compound From the rebase onto craft-libs#483: Hunspell rejects a compound with a NEEDAFFIX part (checked black-box with the hunspell CLI: foo/a + bar/bN under COMPOUNDRULE ab, foobar is wrong), so compound() skips such stems as it does FORBIDDENWORD ones. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2 --- crates/proof/src/affix.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index bde194a4..f7de65f0 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -1429,7 +1429,9 @@ impl AffixDict { { return false; } - let excluded = excluded.and(self.forbidden); + // FORBIDDENWORD and NEEDAFFIX stems are no compound part (Hunspell rejects a compound + // with a NEEDAFFIX part), checked here so the `excluded` slots can't run out. + let bare = |set: &[u16]| [self.forbidden, self.need_affix].into_iter().flatten().all(|f| !has(set, f)); // Char boundaries of `w`, `0` and `w.len()` included. let cuts: Vec = w.char_indices().map(|(i, _)| i).chain(std::iter::once(w.len())).collect(); let n = cuts.len(); @@ -1445,7 +1447,7 @@ impl AffixDict { let slot = parts.get_mut(a * n + b)?; *slot.get_or_insert_with(|| { let (Some(&x), Some(&y)) = (cuts.get(a), cuts.get(b)) else { return None }; - w.get(x..y).and_then(|p| self.stem_set(p)).filter(|set| !excluded.hits(set)) + w.get(x..y).and_then(|p| self.stem_set(p)).filter(|set| !excluded.hits(set) && bare(set)) }) }; self.compound_rules.iter().any(|rule| compound_matches(rule, &mut part, n)) @@ -2513,6 +2515,13 @@ pub(crate) mod tests { } } + /// A NEEDAFFIX stem is no part of a `COMPOUNDRULE` compound (as the `hunspell` CLI answers). + #[test] + fn needaffix_stems_are_no_compound_part() { + let d = AffixDict::from_hunspell("SET UTF-8\nNEEDAFFIX N\nCOMPOUNDRULE 1\nCOMPOUNDRULE ab\n", "2\nfoo/a\nbar/bN\n", &[]).unwrap(); + assert!(d.check("foo") && !d.check("bar") && !d.check("foobar")); + } + #[test] fn hostile_compound_rules_are_refused_or_cheap() { for bad in ["COMPOUNDRULE *a", "COMPOUNDRULE (ab)", "COMPOUNDRULE a**", "COMPOUNDMIN x", "ONLYINCOMPOUND ab"] { From 07bc4672b08973b1cf8e222353b718c9b5fe7045 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Sun, 1 Nov 2026 21:27:51 +0000 Subject: [PATCH 9/9] =?UTF-8?q?CP6.10:=20craft-libs#488=20review=20?= =?UTF-8?q?=E2=80=94=20NEEDAFFIX,=20CIRCUMFIX=20and=20chains=20as=20hunspe?= =?UTF-8?q?ll=201.7.5=20reads=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every case checked black-box against the hunspell 1.7.5 CLI on the reviewers' repro files; checker and forms() changed together: - NEEDAFFIX belongs to its homonym: with foo/NA and foo/B (or a plain foo), foo is right. - A prefix's flag may come from the suffix's continuation classes, with CIRCUMFIX (PFX leg/X + SFX obb/AX: legnagyobb, legeslegnagyobb) or with a NEEDAFFIX prefix (PFX leg/N + SFX obb/A: legnagyobb). - The last suffix of a chain may carry NEEDAFFIX (met by the suffix under it) or CIRCUMFIX (Hunspell ignores it there): work/A, able/B, s/N gives workables. chained_forms_follow_needaffix_circumfix_and_fullstrip asserted the opposite and is corrected. - A prefix and a suffix that both need another affix make no word (re/N + s/N: reworks is wrong). Test needaffix_circumfix_and_fullstrip_match_hunspell holds all 32 repro pairs with Hunspell's answer for each prefix/stem/suffix combination, and checks that forms() lists exactly the right words. A circumfix prefix with two suffixes (neworkables) is not supported: its test is #[ignore]d with the reason. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2 --- crates/proof/src/affix.rs | 294 +++++++++++++++++++++++++++++++++----- 1 file changed, 260 insertions(+), 34 deletions(-) diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index f7de65f0..2c129488 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -1022,6 +1022,9 @@ impl AffixDict { merged.clear(); carried.clear(); let mut is_word = false; + // NEEDAFFIX belongs to its homonym: the word stands alone if any homonym does + // (`foo/NA` and `foo/B`: `foo` is right). + let mut all_need = true; let mut j = i; while let Some((w, a, b, entry_is_word)) = stems.get(j) && w == word @@ -1038,6 +1041,7 @@ impl AffixDict { continue; } is_word = true; + all_need &= d.need_affix.is_some_and(|n| fresh.contains(&n)); fresh.sort_unstable(); fresh.dedup(); fresh.retain(|&f| !has(&merged, f)); @@ -1059,6 +1063,9 @@ impl AffixDict { i = j; continue; } + if !all_need && let Some(n) = d.need_affix { + merged.retain(|&f| f != n); + } for &f in &carried { if has(&merged, f) { continue; @@ -1383,7 +1390,7 @@ impl AffixDict { return false; } // A stem that needs an affix is no word on its own. - if self.stem_flags_except(w, excluded.and(self.need_affix)).is_some() || self.suffixed(w, None, false, excluded) { + if self.stem_flags_except(w, excluded.and(self.need_affix)).is_some() || self.suffixed(w, None, false, false, excluded) { return true; } let mut root = String::with_capacity(w.len() + 8); @@ -1401,7 +1408,8 @@ impl AffixDict { // A circumfix prefix works only with its suffix, and one that needs another affix // only with a suffix too. let circ = self.cont_holds(r, self.circumfix); - if !circ && !self.cont_holds(r, self.need_affix) { + let needs = self.cont_holds(r, self.need_affix); + if !circ && !needs { if self.stem_flags_except(&root, excluded).is_some_and(|f| has(f, r.flag)) { return true; } @@ -1409,7 +1417,7 @@ impl AffixDict { return true; } } - if r.cross && self.suffixed(&root, Some(r.flag), circ, excluded) { + if r.cross && self.suffixed(&root, Some(r.flag), circ, needs, excluded) { return true; } } @@ -1453,12 +1461,16 @@ impl AffixDict { self.compound_rules.iter().any(|rule| compound_matches(rule, &mut part, n)) } - /// Is `w` a stem with one suffix? With `need` set (a prefix was taken off), the stem must - /// carry that flag too and the suffix must allow cross products. `circ` says whether that - /// prefix is a circumfix one: the suffix must be one too, and otherwise must not be. - fn suffixed(&self, w: &str, need: Option, circ: bool, excluded: Excluded) -> bool { - // Fits the suffix rule `r`: circumfix alike, and not in need of another affix unless a prefix is there. - let fits = |r: &Rule| self.cont_holds(r, self.circumfix) == circ && (need.is_some() || !self.cont_holds(r, self.need_affix)); + /// Is `w` a stem with one suffix? With `need` set (a prefix was taken off), the prefix's flag + /// must come from the stem or from the suffix's continuation classes (`leg` + `nagy` + `obb/A`) + /// and the suffix must allow cross products. `circ` says whether that prefix is a circumfix + /// one: the suffix must be one too, and otherwise must not be. `pfx_needs`: the prefix needs + /// another affix (NEEDAFFIX), so the suffix may not need one too (Hunspell rejects `reworks` + /// from `re/N` and `s/N`). + fn suffixed(&self, w: &str, need: Option, circ: bool, pfx_needs: bool, excluded: Excluded) -> bool { + // Fits the suffix rule `r`: circumfix alike, and in need of another affix only when a + // prefix that doesn't is there. + let fits = |r: &Rule| self.cont_holds(r, self.circumfix) == circ && (!self.cont_holds(r, self.need_affix) || (need.is_some() && !pfx_needs)); let mut root = String::with_capacity(w.len() + 8); let mut flags: Vec = Vec::new(); // Each split point from the end: the appended string is `w[b..]`, at most the longest @@ -1475,8 +1487,13 @@ impl AffixDict { // The stem path: `root` is a stem carrying the rule's flag (and the prefix's, // when a cross product is being checked). if let Some(f) = self.stem_flags_except(&root, excluded) - && need.is_none_or(|n| has(f, n)) - && rules().any(|r| has(f, r.flag) && (need.is_none() || r.cross) && fits(r) && r.cond_matches(&root, true)) + && rules().any(|r| { + has(f, r.flag) + && need.is_none_or(|n| has(f, n) || self.cont_holds(r, Some(n))) + && (need.is_none() || r.cross) + && fits(r) + && r.cond_matches(&root, true) + }) { return true; } @@ -1486,8 +1503,10 @@ impl AffixDict { // classes that walk would only repeat the stem path above. if need.is_none() && self.has_cont { flags.clear(); - // Only a flag some continuation class holds can come from another rule. - flags.extend(rules().filter(|r| self.is_cont_flag(r.flag) && fits(r) && r.cond_matches(&root, true)).map(|r| r.flag)); + // Only a flag some continuation class holds can come from another rule. The + // outer suffix of a chain has another affix under it, so its NEEDAFFIX is met, + // and Hunspell ignores its CIRCUMFIX there (`workables` from `s/c`). + flags.extend(rules().filter(|r| self.is_cont_flag(r.flag) && r.cond_matches(&root, true)).map(|r| r.flag)); flags.sort_unstable(); flags.dedup(); if !flags.is_empty() && self.form_has_flag(&root, &flags, INNER_CHAIN, excluded) { @@ -1544,8 +1563,10 @@ impl AffixDict { if !self.need_affix.is_some_and(|n| has(f, n)) { out.push(stem.to_string()); } - // Suffix forms a prefix may be added to, with whether they are circumfix ones. - let mut cross_sfx: Vec<(String, bool)> = Vec::new(); + // Suffix forms a prefix may be added to: the form, whether its suffix is a circumfix one, + // whether it needs another affix, and its continuation classes (which may allow a prefix + // the stem doesn't). + let mut cross_sfx: Vec<(String, bool, bool, u32)> = Vec::new(); // Suffix forms queued with the continuation classes they carry, for chained expansion; // each (form, classes) pair is expanded once. let mut chain: Vec<(String, u32)> = Vec::new(); @@ -1562,7 +1583,7 @@ impl AffixDict { let form = format!("{base}{}", r.add); let circ = self.cont_holds(r, self.circumfix); if r.cross { - cross_sfx.push((form.clone(), circ)); + cross_sfx.push((form.clone(), circ, self.cont_holds(r, self.need_affix), 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)) { @@ -1601,11 +1622,9 @@ impl AffixDict { if r.cont != 0 && queued.insert((next.clone(), r.cont)) { chain.push((next.clone(), r.cont)); } - // 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.extend(non_empty(next)); - } + // The outer suffix of a chain has an affix under it, so its NEEDAFFIX is met, + // and Hunspell ignores its CIRCUMFIX there (`workables` from `s/c`). + out.extend(non_empty(next)); if out.len() >= MAX_FORMS { return out; } @@ -1613,21 +1632,22 @@ impl AffixDict { } } for r in &self.prefixes { - if !has(f, r.flag) { - continue; - } + let on_stem = has(f, r.flag); let circ = self.cont_holds(r, self.circumfix); - if !circ && !self.cont_holds(r, self.need_affix) { + let needs = self.cont_holds(r, self.need_affix); + if on_stem && !circ && !needs { out.extend(self.prefixed(r, stem)); } if r.cross { // Every suffix form looked at counts against the work budget, matching or not. - for (s, s_circ) in &cross_sfx { + for (s, s_circ, s_needs, s_cont) in &cross_sfx { work += 1; if out.len() >= MAX_FORMS || work > MAX_FORMS_WORK { return out; } - if *s_circ != circ { + // The prefix's flag comes from the stem or the suffix's continuation classes; + // circumfix alike; not both in need of another affix. + if !(on_stem || has(self.set(*s_cont), r.flag)) || *s_circ != circ || (needs && *s_needs) { continue; } out.extend(self.prefixed(r, s)); @@ -2522,6 +2542,210 @@ pub(crate) mod tests { assert!(d.check("foo") && !d.check("bar") && !d.check("foobar")); } + /// craft-libs#488 review: NEEDAFFIX, CIRCUMFIX and FULLSTRIP as Hunspell 1.7.5 reads them. + /// Each case is a reviewer's repro `.aff`/`.dic`; the right and wrong words are the `hunspell` + /// CLI's answers on the same files (black-box) for every prefix/stem/suffix combination of + /// them, and `forms()` lists exactly the right ones. + #[test] + fn needaffix_circumfix_and_fullstrip_match_hunspell() { + const CASES: &[(&str, &str, &str, &str)] = &[ + // rv488-hs/circ + ( + "SET UTF-8\nCIRCUMFIX c\nPFX P Y 1\nPFX P 0 ne/c .\nSFX S Y 1\nSFX S 0 y/c .\nSFX T Y 1\nSFX T 0 z .\n", + "1\nstrom/PST\n", + "nestromy strom stromz", + "nenestrom nenestromy nenestromyy nenestromyz nenestromz nenestromzy nenestromzz nestrom nestromyy nestromyz nestromz nestromzy nestromzz stromy stromyy stromyz stromzy stromzz", + ), + // rv488-hs/circ_chain + ( + "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", + "1\nwork/AP\n", + "neworkable work", + "nenework neneworkable neneworkableable neneworkables neneworks neneworksable neneworkss nework neworkableable neworks neworksable neworkss workable workableable workables works worksable workss", + ), + // rv488-hs/circN + ( + "SET UTF-8\nCIRCUMFIX c\nNEEDAFFIX N\nPFX P Y 1\nPFX P 0 ne/cN .\nSFX S Y 1\nSFX S 0 y/c .\n", + "1\nstrom/PS\n", + "nestromy strom", + "nenestrom nenestromy nenestromyy nestrom nestromyy stromy stromyy", + ), + // rv488-hs/circ_nocross + ( + "SET UTF-8\nCIRCUMFIX c\nPFX P Y 1\nPFX P 0 ne/c .\nSFX S N 1\nSFX S 0 y/c .\n", + "1\nstrom/PS\n", + "strom", + "nenestrom nenestromy nenestromyy nestrom nestromy nestromyy stromy stromyy", + ), + // rv488-hs/comp + ("SET UTF-8\nNEEDAFFIX N\nCOMPOUNDRULE 1\nCOMPOUNDRULE ab\n", "2\nfoo/a\nbar/bN\n", "foo", "bar"), + // rv488-hs/full + ("SET UTF-8\nFULLSTRIP\nSFX A Y 1\nSFX A ab x .\nPFX B Y 1\nPFX B ab y .\n", "1\nab/AB\n", "ab x y", ""), + // rv488-hs/full_pfxsfx + ("SET UTF-8\nFULLSTRIP\nCIRCUMFIX c\nPFX P Y 1\nPFX P ab y/c .\nSFX S Y 1\nSFX S ab z/c .\n", "1\nab/PS\n", "ab", "y z"), + // rv488-hs/homo2 + ("SET UTF-8\nNEEDAFFIX N\nSFX A Y 1\nSFX A 0 s .\n", "2\nfoo/NA\nfoo\n", "foo foos", "fooss"), + // rv488-hs/homo + ("SET UTF-8\nNEEDAFFIX N\nSFX A Y 1\nSFX A 0 s .\n", "2\nfoo/NA\nfoo/B\n", "foo foos", "fooss"), + // rv488-hs/pfxna + ( + "SET UTF-8\nNEEDAFFIX N\nPFX P Y 1\nPFX P 0 re/N .\nSFX S Y 1\nSFX S 0 s/N .\n", + "1\nwork/PS\n", + "work", + "rerework rereworks rereworkss rework reworks reworkss works workss", + ), + // rv488-hs/pfxstem + ("SET UTF-8\nNEEDAFFIX N\nPFX P Y 1\nPFX P 0 re .\n", "1\nbaz/NP\n", "rebaz", "baz rerebaz"), + // rv488-hs/pN_s + ( + "SET UTF-8\nNEEDAFFIX N\nPFX P Y 1\nPFX P 0 re/N .\nSFX S Y 1\nSFX S 0 s .\n", + "1\nwork/PS\n", + "reworks work works", + "rerework rereworks rereworkss rework reworkss workss", + ), + // rv488-hs/p_sN + ( + "SET UTF-8\nNEEDAFFIX N\nPFX P Y 1\nPFX P 0 re .\nSFX S Y 1\nSFX S 0 s/N .\n", + "1\nwork/PS\n", + "rework reworks work", + "rerework rereworks rereworkss reworkss works workss", + ), + // rv488-hs/sN_chain + ("SET UTF-8\nNEEDAFFIX N\nSFX B Y 1\nSFX B 0 x/AN .\nSFX A Y 1\nSFX A 0 s .\n", "1\nbar/B\n", "bar barxs", "bars barss barsx barx barxx"), + // rv488-hs/sNnocross + ( + "SET UTF-8\nNEEDAFFIX N\nPFX P Y 1\nPFX P 0 re .\nSFX S N 1\nSFX S 0 s/N .\n", + "1\nwork/PS\n", + "rework work", + "rerework rereworks rereworkss reworks reworkss works workss", + ), + // rv488-hs/stemN_ps + ( + "SET UTF-8\nNEEDAFFIX N\nPFX P Y 1\nPFX P 0 re .\nSFX S Y 1\nSFX S 0 s .\n", + "1\nwork/PSN\n", + "rework reworks works", + "rerework rereworks rereworkss reworkss work workss", + ), + // rv488-hs/zeroaff + ("SET UTF-8\nNEEDAFFIX N\nSFX A Y 1\nSFX A 0 0 .\n", "1\nfoo/NA\n", "foo", ""), + // rv488-second2/t11 + ( + "SET UTF-8\nFULLSTRIP\nSFX A Y 1\nSFX A ab x .\nPFX B Y 1\nPFX B ab y .\nSFX C Y 1\nSFX C 0 z .\n", + "1\nab/ABC\n", + "ab abz x y yz", + "abzz xz yzz", + ), + // rv488-second2/t12 + ("SET UTF-8\nFULLSTRIP\nSFX A Y 1\nSFX A x 0 .\nPFX P Y 1\nPFX P 0 q .\n", "1\nx/AP\n", "q qx x", "qq qqx"), + // rv488-second2/t13 + ("SET UTF-8\nFULLSTRIP\nSFX A Y 1\nSFX A x 0/B .\nSFX B Y 1\nSFX B 0 y .\n", "1\nx/A\n", "x y", "xy xyy"), + // rv488-second2/t1 + ("SET UTF-8\nNEEDAFFIX N\nSFX A Y 1\nSFX A 0 s .\n", "2\nfoo/N\nfoo/A\n", "foo foos", "fooss"), + // rv488-second2/t2 + ( + "SET UTF-8\nCIRCUMFIX X\nPFX A Y 1\nPFX A 0 leg/X .\nPFX B Y 1\nPFX B 0 legesleg/X .\nSFX C Y 3\nSFX C 0 obb .\nSFX C 0 obb/AX .\nSFX C 0 obb/BX .\n", + "1\nnagy/C\n", + "legeslegnagyobb legnagyobb nagy nagyobb", + "legesleglegeslegnagy legesleglegeslegnagyobb legesleglegeslegnagyobbobb legesleglegnagy legesleglegnagyobb legesleglegnagyobbobb legeslegnagy legeslegnagyobbobb leglegeslegnagy leglegeslegnagyobb leglegeslegnagyobbobb leglegnagy leglegnagyobb leglegnagyobbobb legnagy legnagyobbobb nagyobbobb", + ), + // rv488-second2/t30 + ( + "SET UTF-8\nNEEDAFFIX N\nPFX P Y 1\nPFX P 0 re .\nSFX B Y 1\nSFX B 0 x/N .\n", + "1\nbar/BP\n", + "bar rebar rebarx", + "barx barxx rebarxx rerebar rerebarx rerebarxx", + ), + // rv488-second2/t31 + ( + "SET UTF-8\nCIRCUMFIX c\nPFX P Y 1\nPFX P 0 ne .\nPFX Q Y 1\nPFX Q 0 qe/c .\nSFX S Y 1\nSFX S 0 y/c .\nSFX T Y 1\nSFX T 0 z .\n", + "1\nstrom/PQST\n", + "nestrom nestromz qestromy strom stromz", + "nenestrom nenestromy nenestromyy nenestromyz nenestromz nenestromzy nenestromzz neqestrom neqestromy neqestromyy neqestromyz neqestromz neqestromzy neqestromzz nestromy nestromyy nestromyz nestromzy nestromzz qenestrom qenestromy qenestromyy qenestromyz qenestromz qenestromzy qenestromzz qeqestrom qeqestromy qeqestromyy qeqestromyz qeqestromz qeqestromzy qeqestromzz qestrom qestromyy qestromyz qestromz qestromzy qestromzz stromy stromyy stromyz stromzy stromzz", + ), + // rv488-second2/t32 + ( + "SET UTF-8\nNEEDAFFIX N\nPFX P Y 1\nPFX P 0 re/N .\nSFX B Y 1\nSFX B 0 x/N .\n", + "1\nbar/BP\n", + "bar", + "barx barxx rebar rebarx rebarxx rerebar rerebarx rerebarxx", + ), + // rv488-second2/t3 + ( + "SET UTF-8\nPFX A Y 1\nPFX A 0 leg .\nSFX C Y 1\nSFX C 0 obb/A .\n", + "1\nnagy/C\n", + "legnagyobb nagy nagyobb", + "leglegnagy leglegnagyobb leglegnagyobbobb legnagy legnagyobbobb nagyobbobb", + ), + // rv488-second2/t4 + ( + "SET UTF-8\nCIRCUMFIX X\nPFX A Y 1\nPFX A 0 leg/X .\nSFX C Y 2\nSFX C 0 obb .\nSFX C 0 obb/X .\n", + "1\nnagy/AC\n", + "legnagyobb nagy nagyobb", + "leglegnagy leglegnagyobb leglegnagyobbobb legnagy legnagyobbobb nagyobbobb", + ), + // rv488-second2/t5 + ( + "SET UTF-8\nNEEDAFFIX N\nPFX A Y 1\nPFX A 0 leg/N .\nSFX C Y 1\nSFX C 0 obb/A .\n", + "1\nnagy/C\n", + "legnagyobb nagy nagyobb", + "leglegnagy leglegnagyobb leglegnagyobbobb legnagy legnagyobbobb nagyobbobb", + ), + // rv488-second2/t6 + ( + "SET UTF-8\nNEEDAFFIX N\nPFX A Y 1\nPFX A 0 leg .\nSFX C Y 1\nSFX C 0 obb/AN .\n", + "1\nnagy/C\n", + "legnagyobb nagy", + "leglegnagy leglegnagyobb leglegnagyobbobb legnagy legnagyobbobb nagyobb nagyobbobb", + ), + // rv488-second2/t7 + ( + "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 workable workables", + "workableable works worksable workss", + ), + // rv488-second2/t8 + ( + "SET UTF-8\nNEEDAFFIX N\nPFX P Y 1\nPFX P 0 re .\nSFX Z Y 1\nSFX Z 0 0 .\nSFX S Y 1\nSFX S 0 s .\n", + "2\nfoo/NPS\nbar/NZ\n", + "bar foos refoo refoos", + "bars barss foo fooss rebar rebars rebarss refooss rerebar rerebars rerebarss rerefoo rerefoos rerefooss", + ), + // rv488-second2/t9 + ( + "SET UTF-8\nCIRCUMFIX c\nPFX P Y 1\nPFX P 0 ne/c .\nSFX A Y 1\nSFX A 0 able/B .\nSFX B Y 1\nSFX B 0 s/c .\n", + "1\nwork/AP\n", + "work workable workables", + "nenework neneworkable neneworkableable neneworkables neneworks neneworksable neneworkss nework neworkable neworkableable neworkables neworks neworksable neworkss workableable works worksable workss", + ), + ]; + for (aff, dic, right, wrong) in CASES { + let d = AffixDict::from_hunspell(aff, dic, &[]).unwrap(); + for w in right.split_whitespace() { + assert!(d.check(w), "{w} is right:\n{aff}{dic}"); + } + for w in wrong.split_whitespace() { + assert!(!d.check(w), "{w} is wrong:\n{aff}{dic}"); + } + let mut forms: Vec = dic.lines().skip(1).flat_map(|l| d.forms(l.split('/').next().unwrap_or(""))).collect(); + forms.sort(); + forms.dedup(); + let mut want: Vec<&str> = right.split_whitespace().collect(); + want.sort_unstable(); + assert_eq!(forms, want, "forms():\n{aff}{dic}"); + } + } + + /// A circumfix prefix with two suffixes (`ne` + `work` + `able/Bc` + `s`): Hunspell 1.7.5 + /// takes `neworkables`; this engine allows a prefix with one suffix only. + #[test] + #[ignore = "prefix + two suffixes is not supported (craft-libs#488 review P3)"] + fn a_circumfix_prefix_with_a_suffix_chain() { + 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(); + assert!(d.check("neworkables")); + } + #[test] fn hostile_compound_rules_are_refused_or_cheap() { for bad in ["COMPOUNDRULE *a", "COMPOUNDRULE (ab)", "COMPOUNDRULE a**", "COMPOUNDMIN x", "ONLYINCOMPOUND ab"] { @@ -3120,9 +3344,9 @@ x/12 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`. + /// A chained suffix form is listed when the checker accepts it: also when its last suffix + /// needs another affix or is a circumfix one (as hunspell 1.7.5 reads a chain), 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| { @@ -3136,11 +3360,13 @@ x/12 (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"); + // The last suffix's NEEDAFFIX is met by the one under it (hunspell 1.7.5 takes it). + assert_eq!(forms, ["work", "workable", "workables"]); + assert!(d.contains("workables"), "its last suffix has another affix under it"); 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"); + // Hunspell 1.7.5 ignores a chain's last suffix's CIRCUMFIX: `workables` is right. + assert_eq!(forms, ["work", "workable", "workables"]); + assert!(d.contains("workables"), "a chain's last suffix needs no prefix"); 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"]);