diff --git a/crates/proof/examples/affix_check.rs b/crates/proof/examples/affix_check.rs index 31692618c..329c4fcb9 100644 --- a/crates/proof/examples/affix_check.rs +++ b/crates/proof/examples/affix_check.rs @@ -6,6 +6,8 @@ //! cargo run --release -p craft-proof --example affix_check -- check de_DE.aff de_DE.dic < words.txt //! # 2000 candidates (forms of random stems glued together, some with a letter changed), seed 7. //! cargo run --release -p craft-proof --example affix_check -- gen de_DE.aff de_DE.dic 2000 7 > words.txt +//! # Single words instead: forms of random stems, and stems with another stem's ending. +//! cargo run --release -p craft-proof --example affix_check -- single de_DE.aff de_DE.dic 2000 7 > words.txt //! ``` //! //! The answers are compared with `hunspell -d -l` on the same words @@ -83,6 +85,36 @@ fn generate(d: &AffixDict, n: usize, seed: u64) -> Vec { out } +/// Single words (no compounds): forms of random stems, and stems with another stem's ending +/// (mostly wrong, some right: an ending the stem's own homonym allows or not). +fn single(d: &AffixDict, n: usize, seed: u64) -> Vec { + let stems: Vec<&str> = d.stems().filter(|s| s.chars().all(char::is_alphabetic)).collect(); + let mut rng = Rng(seed.max(1)); + let mut endings: Vec = Vec::new(); + for _ in 0..200 { + let s = stems.get(rng.below(stems.len())).copied().unwrap_or_default(); + endings.extend(d.forms(s).iter().filter_map(|f| f.strip_prefix(s)).filter(|e| !e.is_empty()).map(str::to_string)); + } + endings.sort(); + endings.dedup(); + let mut out = Vec::with_capacity(n); + while out.len() < n { + let s = stems.get(rng.below(stems.len())).copied().unwrap_or_default(); + let forms = d.forms(s); + if let Some(f) = forms.get(rng.below(forms.len())) { + out.push(f.clone()); + } + if let Some(e) = endings.get(rng.below(endings.len())) { + out.push(format!("{s}{e}")); + } + out.retain(|w| w.chars().all(char::is_alphabetic)); + } + // Letters only: the other checker's command line splits words at anything else. + out.retain(|w| w.chars().all(char::is_alphabetic)); + out.truncate(n); + out +} + fn main() -> Result<(), String> { let args: Vec = std::env::args().skip(1).collect(); match args.as_slice() { @@ -112,15 +144,16 @@ fn main() -> Result<(), String> { eprintln!("{} words in {took:?}", words.len()); Ok(()) } - [cmd, aff, dic, n, seed] if cmd == "gen" => { + [cmd, aff, dic, n, seed] if cmd == "gen" || cmd == "single" => { let d = load(aff, dic)?; let n: usize = n.parse().map_err(|_| "n is a number")?; let seed: u64 = seed.parse().map_err(|_| "seed is a number")?; - for w in generate(&d, n, seed) { + let words = if cmd == "gen" { generate(&d, n, seed) } else { single(&d, n, seed) }; + for w in words { println!("{w}"); } Ok(()) } - _ => Err("usage: affix_check check < words | affix_check gen ".into()), + _ => Err("usage: affix_check check < words | affix_check gen|single ".into()), } } diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index 7bec2722a..2eccbc299 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -43,6 +43,8 @@ use crate::patterns::{FxHasher, FxMap}; mod compound; +pub(crate) use compound::MAX_SUGGEST_COMPOUNDS; + const MAGIC: &str = "WCAFF1"; /// Most bytes the compact format inflates to, written or read. const MAX_INFLATED: usize = 64 << 20; @@ -357,6 +359,11 @@ pub struct AffixDict { compound_firsts: Vec, /// Flag-based compounding (`COMPOUNDFLAG`…), if the dictionary compounds by flags. cpd: Option>, + /// Stems with `ONLYINCOMPOUND` homonyms beside others: stem index → those homonyms' flags + /// (compound parts only), while `flags` holds the others'. + cpd_sets: FxMap, + /// Those stems' sets with all their homonyms' flags, for `COMPOUNDRULE` parts. + rule_sets: FxMap, /// Appended string → the prefix rules adding it, in rule order. pfx_index: FxMap, Vec>, /// Longest appended prefix, in chars. @@ -490,6 +497,12 @@ pub fn capitalize(w: &str) -> String { } } +/// `w` with its first letter in lower case. +fn decapitalize(w: &str) -> String { + let mut c = w.chars(); + c.next().map(|f| f.to_lowercase().chain(c).collect()).unwrap_or_default() +} + /// Parsed rules and tables, shared by the Hunspell reader and the compact format. #[derive(Default)] struct Rules { @@ -777,8 +790,10 @@ impl FlagMode { /// Stem flags that rule a lookup out: `FORBIDDENWORD` always, and per lookup `NOSUGGEST` and /// `SUBSTANDARD` (suggestions), `KEEPCASE` (case variants of a word) or `NEEDAFFIX` (a stem on /// its own). +/// The second field: no flag-based compounds; the third: a suggestion candidate, whose compound +/// search gets a small budget ([`compound::MAX_SUGGEST_WORK`]). #[derive(Clone, Copy, Debug, Default)] -struct Excluded([Option; 5], bool); +struct Excluded([Option; 5], bool, bool); impl Excluded { /// Does the flag set `set` carry an excluded flag? @@ -796,6 +811,20 @@ impl Excluded { self.1 } + fn suggesting(self) -> bool { + self.2 + } + + /// These exclusions without `flag`. + fn without(mut self, flag: Option) -> Excluded { + for slot in &mut self.0 { + if *slot == flag { + *slot = None; + } + } + self + } + /// These exclusions and `flag` too. fn and(mut self, flag: Option) -> Excluded { if flag.is_some() @@ -842,6 +871,44 @@ fn has(set: &[u16], flag: u16) -> bool { set.binary_search(&flag).is_ok() } +/// Add a homonym's sorted flags `fresh` to the sorted set `set`; `false` (and nothing added) when +/// that would take it past [`MAX_FLAGS_PER_STEM`]. +fn merge_homonym(set: &mut Vec, fresh: &[u16]) -> bool { + let before = set.len(); + let add: Vec = fresh.iter().copied().filter(|&f| !has(set, f)).collect(); + if add.is_empty() { + // A repeated homonym adds nothing: no sort (200,000 duplicate lines of a stem with + // thousands of flags would each pay one). + return true; + } + if before.saturating_add(add.len()) > MAX_FLAGS_PER_STEM { + return false; + } + set.extend(add); + // A sorted run and a sorted tail: the stable sort merges them in linear time. + set.sort(); + true +} + +/// Put the exclusion flags `carried` (FORBIDDENWORD, NOSUGGEST, SUBSTANDARD of any homonym) back +/// into the sorted set `set`, making room by dropping a flag that excludes nothing (there are at +/// most three exclusion flags, so a full set always has one). +fn carry_exclusions(set: &mut Vec, carried: &[u16], exclusions: &[u16]) { + for &f in carried { + if has(set, f) { + continue; + } + if set.len() >= MAX_FLAGS_PER_STEM + && let Some(at) = set.iter().rposition(|g| !exclusions.contains(g)) + { + set.remove(at); + } + if let Err(at) = set.binary_search(&f) { + set.insert(at, f); + } + } +} + /// Do two sorted flag sets share a flag? (A binary search per flag of the smaller one.) fn intersects(a: &[u16], b: &[u16]) -> bool { let (small, large) = if a.len() <= b.len() { (a, b) } else { (b, a) }; @@ -1172,20 +1239,25 @@ impl AffixDict { let mut i = 0; let mut dropped = 0usize; let mut merged: Vec = Vec::new(); + let mut solo: Vec = Vec::new(); + let mut only_set: Vec = Vec::new(); let mut fresh: Vec = Vec::new(); let mut carried: Vec = Vec::new(); while i < stems.len() { let Some((word, ..)) = stems.get(i) else { break }; merged.clear(); + solo.clear(); + only_set.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; - // So does ONLYINCOMPOUND: a homonym with neither stands alone (German `schulen/DIXY` - // beside `schulen/Sozm`), but `buch/Sozm` beside `buch/hke` (NEEDAFFIX) doesn't, as - // in the CLI. - let mut alone = false; + // So does ONLYINCOMPOUND, with its flags: beside homonyms without it, the homonyms + // with it are kept apart (`cpd_sets`), only for compound parts (German + // `schulen/DIXY` beside `schulen/Sozm`: `schulen` alone, but not `schulens`), as in + // the CLI. + let (mut solo_need, mut only_need, mut any_solo, mut any_only) = (true, true, false, false); let mut j = i; while let Some((w, a, b, entry_is_word)) = stems.get(j) && w == word @@ -1202,22 +1274,24 @@ impl AffixDict { continue; } is_word = true; - all_need &= d.need_affix.is_some_and(|n| fresh.contains(&n)); - alone |= !d.only_in_compound.is_some_and(|o| fresh.contains(&o)) && !d.need_affix.is_some_and(|n| fresh.contains(&n)); + let need = d.need_affix.is_some_and(|n| fresh.contains(&n)); + let only = d.only_in_compound.is_some_and(|o| fresh.contains(&o)); + all_need &= need; fresh.sort_unstable(); fresh.dedup(); - fresh.retain(|&f| !has(&merged, f)); + if only { + any_only = true; + only_need &= need; + merge_homonym(&mut only_set, &fresh); + } else { + any_solo = true; + solo_need &= need; + merge_homonym(&mut solo, &fresh); + } // Homonyms can add up to more flags than one entry holds; past MAX_FLAGS_PER_STEM // an entry's other flags are dropped (its exclusions are carried below), so the // compact data never holds a set its reader refuses. - if fresh.is_empty() { - // A repeated homonym adds nothing: no sort (200,000 duplicate lines of a - // stem with thousands of flags would each pay one). - } else if merged.len().saturating_add(fresh.len()) <= MAX_FLAGS_PER_STEM { - merged.extend_from_slice(&fresh); - // A sorted run and a sorted tail: the stable sort merges them in linear time. - merged.sort(); - } else { + if !merge_homonym(&mut merged, &fresh) { dropped += 1; } } @@ -1228,26 +1302,35 @@ impl AffixDict { if !all_need && let Some(n) = d.need_affix { merged.retain(|&f| f != n); } - if alone { - merged.retain(|&f| Some(f) != d.only_in_compound && Some(f) != d.need_affix); - } - for &f in &carried { - if has(&merged, f) { - continue; + let mixed = any_solo && any_only; + let cpd_id = if mixed { + for (set, all_need) in [(&mut solo, solo_need), (&mut only_set, only_need)] { + if !all_need && let Some(n) = d.need_affix { + set.retain(|&f| f != n); + } + carry_exclusions(set, &carried, &exclusions); } - // Make room by dropping a flag that excludes nothing (there are at most three - // exclusion flags, so a full set always has one). - if merged.len() >= MAX_FLAGS_PER_STEM - && let Some(at) = merged.iter().rposition(|g| !exclusions.contains(g)) - { - merged.remove(at); - } - if let Err(at) = merged.binary_search(&f) { - merged.insert(at, f); + // `COMPOUNDRULE` parts read all the homonyms' flags. + if !d.compound_rules.is_empty() { + carry_exclusions(&mut merged, &carried, &exclusions); + let all = d.intern(&mut set_ids, &mut merged)?; + if let Ok(at) = u32::try_from(d.flags.len()) { + d.rule_sets.insert(at, all); + } } + std::mem::swap(&mut merged, &mut solo); + Some(d.intern(&mut set_ids, &mut only_set)?) + } else { + carry_exclusions(&mut merged, &carried, &exclusions); + None + }; + if let Some(c) = cpd_id + && let Ok(at) = u32::try_from(d.flags.len()) + { + d.cpd_sets.insert(at, c); } let id = d.intern(&mut set_ids, &mut merged)?; - if merged.iter().any(|f| compound_flags.contains(f)) + if [Some(id), cpd_id].into_iter().flatten().any(|s| d.set(s).iter().any(|f| compound_flags.contains(f))) && let Some(c) = word.chars().next() && !d.compound_firsts.contains(&c) { @@ -1429,7 +1512,8 @@ impl AffixDict { if !suggestible { return e; } - let e = e.and(self.no_suggest).and(self.substandard); + let mut e = e.and(self.no_suggest).and(self.substandard); + e.2 = true; // `MAXCPDSUGS 0`: compounds are right but never suggested. if self.cpd.as_ref().is_some_and(|c| c.no_suggestions) { e.without_compounds() } else { e } } @@ -1546,6 +1630,18 @@ impl AffixDict { self.contains_except(&self.normalize(w), self.excluded(true)) } + /// [`AffixDict::contains_suggestible`], without flag-based compounds when `compounds` is + /// false (suggestion candidates past the first [`compound::MAX_SUGGEST_COMPOUNDS`]). + pub(crate) fn contains_suggestible_with(&self, w: &str, compounds: bool) -> bool { + let e = self.excluded(true); + self.contains_except(&self.normalize(w), if compounds { e } else { e.without_compounds() }) + } + + /// Whether the dictionary compounds by flags (suggestion candidates then cost more). + pub(crate) fn flag_compounds(&self) -> bool { + self.cpd.is_some() + } + /// `w` after the `ICONV` conversions (at each position the longest match wins) and without /// the `IGNORE` characters. Input longer than [`MAX_INPUT_BYTES`] is no word and comes back /// unchanged, so the length check after this rejects it without converting it. @@ -1655,7 +1751,10 @@ 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) && bare(set)) + w.get(x..y) + .and_then(|p| self.stem_lookup(p)) + .map(|(stem, set)| self.rule_sets.get(&stem).map_or(set, |&s| self.set(s))) + .filter(|set| !excluded.hits(set) && bare(set)) }) }; self.compound_rules.iter().any(|rule| compound_matches(rule, &mut part, n)) @@ -1756,7 +1855,10 @@ impl AffixDict { } let keep = excluded.and(self.keepcase); match casing(&w) { - Casing::Lower | Casing::Mixed => false, + Casing::Lower => false, + // A compound may join a capitalized stem to others, and the word start with a + // capital (`FooQux` from `foo` and `Qux`: right in the CLI). + Casing::Mixed => w.chars().next().is_some_and(char::is_uppercase) && self.flag_compound(&decapitalize(&w), excluded, true), Casing::Initial => { let lower = w.to_lowercase(); // CHECKSHARPS: a KEEPCASE word with `ß` may be capitalized (`Gruß` for `Gruß/k`). @@ -1995,7 +2097,7 @@ impl AffixDict { // In one-character mode a directive flag that no stem or rule uses isn't written, as // before flag ids existed, so those dictionaries' compact data stays byte for byte the same. let used = |flag: u16| { - self.flags.iter().any(|&id| self.set(id).contains(&flag)) + self.flags.iter().chain(self.cpd_sets.values()).any(|&id| self.set(id).contains(&flag)) || self.prefixes.iter().chain(&self.suffixes).any(|r| r.flag == flag || self.set(r.cont).contains(&flag)) }; if self.full_strip { @@ -2082,6 +2184,15 @@ impl AffixDict { raw.extend_from_slice(flags.as_bytes()); } raw.push(b'\n'); + // The ONLYINCOMPOUND homonyms' flags: the same stem again, merged back on reading. + if let Some(&c) = u32::try_from(i).ok().and_then(|i| self.cpd_sets.get(&i)) { + raw.push(b.len().min(255) as u8); + raw.extend_from_slice(b.get(b.len().min(255)..).unwrap_or_default()); + let mut flags = String::from("/"); + self.write_flags(self.set(c), &mut flags); + raw.extend_from_slice(flags.as_bytes()); + raw.push(b'\n'); + } prev = b; } // What [`AffixDict::from_bytes`] would refuse to inflate is refused here. diff --git a/crates/proof/src/affix/compound.rs b/crates/proof/src/affix/compound.rs index 31be0c8b1..bc5b513d7 100644 --- a/crates/proof/src/affix/compound.rs +++ b/crates/proof/src/affix/compound.rs @@ -11,7 +11,7 @@ //! carries `COMPOUNDPERMITFLAG`. Two suffixes in a row (continuation classes) only on the last. //! - An affix carrying `ONLYINCOMPOUND` is only valid inside a compound: a prefix anywhere, a //! suffix anywhere but on the last part (the CLI's answers; the word alone is no word). -//! - `COMPOUNDFORBIDFLAG` on a stem or an affix: that part may only be last. +//! - `COMPOUNDFORBIDFLAG` on a stem or a suffix: that part may only be last; on a prefix: no part. //! - A stem with `NEEDAFFIX` is a part only with an affix; `FORBIDDENWORD` stems are no part, and //! a forbidden word is never rebuilt from parts. //! - `COMPOUNDWORDMAX`: most parts (a `COMPOUNDROOT` stem counts as two). With `COMPOUNDSYLLABLE` @@ -22,16 +22,20 @@ //! - `CHECKCOMPOUNDCASE`: no capital on either side of a boundary, unless the other side is `-`. //! - `CHECKCOMPOUNDTRIPLE`: no three equal letters across a boundary (`schiff|fahrt`). //! `SIMPLIFIEDTRIPLE`: a part ending in a double letter may share its last letter with the next -//! part (`schiffahrt` = `schiff` + `fahrt`). +//! part (`schiffahrt` = `schiff` + `fahrt`), but with `CHECKCOMPOUNDTRIPLE` not leave three +//! equal letters (`busssen`). //! - `CHECKCOMPOUNDPATTERN end[/flag] begin[/flag]`: no boundary where the part before ends with //! `end` and the part after begins with `begin` (and they carry the flags); `0` for `end` means -//! an unaffixed part. The optional replacement field is refused. +//! a part without a suffix. The optional replacement field is refused. //! - `CHECKCOMPOUNDREP`: no compound that a `REP` replacement turns into a word (not a compound), //! checked on every two neighbouring parts and on every run of parts that ends the word. //! - A compound of three or more parts is rejected when one slip turns it into a word (not a //! compound): see [`AffixDict::near_word`]. The CLI's answers, with or without //! `CHECKCOMPOUNDREP`. -//! - `FORCEUCASE` on the last part: only the capitalized word (or the word in capitals) is right. +//! - `FORCEUCASE` on the last part's stem: a word with no capital is wrong. +//! - Case: `KEEPCASE` holds for the first part only; a capitalized stem may be a part, and a word +//! with capitals inside may start with a capital (`FooQux`). +//! - `ONLYINCOMPOUND` homonyms beside others keep their own flags (compound parts only). //! - `MAXCPDSUGS 0`: compounds are never suggested. //! //! Not read: `SYLLABLENUM` (Hungarian), which the man page doesn't describe; it is ignored. @@ -46,12 +50,19 @@ use super::{AffixDict, Excluded, FlagKey, FlagMode, Rule, has}; pub(super) const MAX_PARTS: usize = 16; /// Most steps one compound check may take (affix rules tried, parts looked up, splits followed). pub(super) const MAX_WORK: usize = 1 << 16; +/// Most steps the compound search of one suggestion candidate may take. +pub(crate) const MAX_SUGGEST_WORK: usize = 1 << 11; +/// Most candidates of one suggestion call whose compound search runs; the later ones are +/// checked as simple words only, so a long garbled word's suggestions stay quick. +pub(crate) const MAX_SUGGEST_COMPOUNDS: usize = 128; /// Most `CHECKCOMPOUNDPATTERN` lines (the Dutch dictionary has 52). const MAX_PATTERNS: usize = 512; /// Longest text on either side of a `CHECKCOMPOUNDPATTERN`, in bytes. const MAX_PATTERN_BYTES: usize = 64; /// Most vowels a `COMPOUNDSYLLABLE` line may list. const MAX_VOWELS: usize = 64; +/// Most ways one piece of a word may be a part (distinct flags and places). +const MAX_INFOS: usize = 256; /// Most `MAP` variants, `TRY` letters and `KEY` row letters the three-part check tries. const MAX_MAP_VARIANTS: usize = 256; const MAX_TRY: usize = 128; @@ -359,14 +370,16 @@ impl Compounding { } /// One way a piece of the word can be a compound part. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] struct Info { /// Where it may sit: [`FIRST`], [`MIDDLE`], [`LAST`]. pos: u8, /// Which of [`Compounding::marks`] its flags hold. marks: u64, - /// No affix. + /// No suffix (`CHECKCOMPOUNDPATTERN`'s `0`). unmodified: bool, + /// The stem carries `FORCEUCASE`. + force: bool, /// The stem's index (`CHECKCOMPOUNDDUP` compares stems). stem: u32, /// How many words it counts as (`COMPOUNDROOT`: two). @@ -393,6 +406,8 @@ struct Search<'a> { cuts: Vec, chars: Vec, excluded: Excluded, + /// The first part's (KEEPCASE holds there). + first_excluded: Excluded, capitalized: bool, /// Per piece `(start, end)` in chars: its infos as a range of `infos`. memo: Vec>, @@ -415,6 +430,10 @@ impl AffixDict { if self.is_forbidden(w) { return false; } + // KEEPCASE holds for the first part only (from `baz/K`, `Foobaz` and `FOOBAZ` are right + // in the CLI, `Bazfoo` and `BAZFOO` wrong). + let first_excluded = excluded; + let excluded = excluded.without(self.keepcase); let cuts: Vec = w.char_indices().map(|(i, _)| i).chain(std::iter::once(w.len())).collect(); let chars: Vec = w.chars().collect(); let n = cuts.len(); @@ -424,11 +443,13 @@ impl AffixDict { cuts, chars, excluded, + first_excluded, capitalized, memo: vec![None; n * n], infos: Vec::new(), path: Vec::with_capacity(MAX_PARTS), - work: 0, + // A suggestion candidate's search gets MAX_SUGGEST_WORK steps of the budget. + work: if excluded.suggesting() { MAX_WORK - MAX_SUGGEST_WORK } else { 0 }, near: None, }; self.parts_from(&mut s, 0) @@ -457,6 +478,11 @@ impl AffixDict { }; let (a, b) = self.infos_of(s, start, end); for k in a..b { + // Each way of the piece costs a step, and a step per pattern at the boundary. + s.work += 1 + s.c.patterns.len(); + if s.work > MAX_WORK { + return false; + } let Some(info) = s.infos.get(k as usize).copied() else { continue }; if info.pos & place == 0 || !self.boundary_ok(s, start, &info) { continue; @@ -493,11 +519,16 @@ impl AffixDict { if let (Some(&x), Some(&y)) = (s.cuts.get(a), s.cuts.get(b)) && let Some(piece) = s.w.get(x..y) { + // Deduplicated by hashing, and at most MAX_INFOS per piece (each one is tried at + // every split, against every pattern). + let mut seen: std::collections::HashSet> = Default::default(); let mut found: Vec = Vec::new(); let mut work = s.work; - self.analyses(piece, s.excluded, &mut work, &mut |an: Analysis| { - if let Some(info) = self.info(s.c, &an) - && !found.contains(&info) + let excluded = if a == 0 { s.first_excluded } else { s.excluded }; + self.analyses(piece, excluded, &mut work, &mut |an: Analysis| { + if found.len() < MAX_INFOS + && let Some(info) = self.info(s.c, &an) + && seen.insert(info) { found.push(info); } @@ -516,6 +547,10 @@ impl AffixDict { fn info(&self, c: &Compounding, an: &Analysis) -> Option { let sets = [Some(an.set), an.pfx.map(|r| self.set(r.cont)), an.sfx.map(|r| self.set(r.cont)), an.inner.map(|r| self.set(r.cont))]; let carries = |f: Option| f.is_some_and(|f| sets.iter().flatten().any(|s| has(s, f))); + // COMPOUNDFORBIDFLAG on a prefix: no part anywhere (`bar|defoo` is wrong in the CLI). + if an.pfx.is_some_and(|r| self.cont_holds(r, c.forbid)) { + return None; + } let mut pos = 0; if carries(c.flag) { pos = FIRST | MIDDLE | LAST; @@ -554,16 +589,19 @@ impl AffixDict { } } let words = if carries(c.root) { 2 } else { 1 }; - Some(Info { pos, marks, unmodified: an.pfx.is_none() && an.sfx.is_none(), stem: an.stem, words }) + // FORCEUCASE counts on the stem only (a suffix's continuation class holding it is ignored); + // a pattern's `0` means no suffix (a prefix doesn't count): the CLI's answers. + let force = c.force_ucase.is_some_and(|f| has(an.set, f)); + Some(Info { pos, marks, unmodified: an.sfx.is_none(), force, stem: an.stem, words }) } /// Every way `p` is a stem, or a stem with a prefix, one or two suffixes, or a prefix and a /// suffix, as a compound part may be (`ONLYINCOMPOUND` affixes and stems included). fn analyses<'a>(&'a self, p: &str, excluded: Excluded, work: &mut usize, emit: &mut impl FnMut(Analysis<'a>)) { - if let Some((stem, set)) = self.part_stem(p, excluded) - && !self.need_affix.is_some_and(|n| has(set, n)) - { - emit(Analysis { stem, set, pfx: None, sfx: None, inner: None }); + for (stem, set) in self.part_stems(p, excluded) { + if !self.need_affix.is_some_and(|n| has(set, n)) { + emit(Analysis { stem, set, pfx: None, sfx: None, inner: None }); + } } self.suffix_analyses(p, None, excluded, work, emit); let mut root = String::with_capacity(p.len() + 8); @@ -586,12 +624,12 @@ impl AffixDict { continue; } let (circ, needs) = (self.cont_holds(r, self.circumfix), self.cont_holds(r, self.need_affix)); - if !circ - && !needs - && let Some((stem, set)) = self.part_stem(&root, excluded) - && has(set, r.flag) - { - emit(Analysis { stem, set, pfx: Some(r), sfx: None, inner: None }); + if !circ && !needs { + for (stem, set) in self.part_stems(&root, excluded) { + if has(set, r.flag) { + emit(Analysis { stem, set, pfx: Some(r), sfx: None, inner: None }); + } + } } if r.cross { self.suffix_analyses(&root, Some(r), excluded, work, emit); @@ -613,7 +651,7 @@ impl AffixDict { root.push_str(base); root.push_str(&g.strip); let rules = || g.rules.iter().filter_map(|&i| self.suffixes.get(i as usize)); - if let Some((stem, set)) = self.part_stem(&root, excluded) { + for (stem, set) in self.part_stems(&root, excluded) { for r in rules() { *work += 1; if *work > MAX_WORK { @@ -673,22 +711,23 @@ impl AffixDict { root.clear(); root.push_str(base); root.push_str(&g.strip); - let Some((stem, set)) = self.part_stem(&root, excluded) else { continue }; - for r in g.cont_rules.iter().filter_map(|&i| self.suffixes.get(i as usize)) { - *work += 1; - if *work > MAX_WORK { - return; - } - if !has(set, r.flag) - || self.cont_holds(r, self.circumfix) - || self.cont_holds(r, self.only_in_compound) - || !r.cond_matches(&root, true) - { - continue; - } - for &outer in outers.iter().filter(|o| self.cont_holds(r, Some(o.flag))) { + for (stem, set) in self.part_stems(&root, excluded) { + for r in g.cont_rules.iter().filter_map(|&i| self.suffixes.get(i as usize)) { *work += 1; - emit(Analysis { stem, set, pfx: None, sfx: Some(outer), inner: Some(r) }); + if *work > MAX_WORK { + return; + } + if !has(set, r.flag) + || self.cont_holds(r, self.circumfix) + || self.cont_holds(r, self.only_in_compound) + || !r.cond_matches(&root, true) + { + continue; + } + for &outer in outers.iter().filter(|o| self.cont_holds(r, Some(o.flag))) { + *work += 1; + emit(Analysis { stem, set, pfx: None, sfx: Some(outer), inner: Some(r) }); + } } } } @@ -696,8 +735,12 @@ impl AffixDict { } /// The stem `w` as a compound part: its index and flags, unless it is forbidden or excluded. - fn part_stem(&self, w: &str, excluded: Excluded) -> Option<(u32, &[u16])> { - self.stem_lookup(w).filter(|(_, set)| !excluded.hits(set) && !self.forbidden.is_some_and(|f| has(set, f))) + /// A stem with `ONLYINCOMPOUND` homonyms beside others is two: each group's flags apply on + /// their own (`foo/XOS` and `foo/T`: `foos` is a part, `foot` is not, as in the CLI). + fn part_stems(&self, w: &str, excluded: Excluded) -> impl Iterator { + let found = self.stem_lookup(w); + let only = found.and_then(|(stem, _)| self.cpd_sets.get(&stem).map(|&c| (stem, self.set(c)))); + found.into_iter().chain(only).filter(move |(_, set)| !excluded.hits(set) && !self.forbidden.is_some_and(|f| has(set, f))) } /// May a part with `info` start at char `start`, after the last part of `s.path` @@ -716,6 +759,11 @@ impl AffixDict { return false; } } + // A SIMPLIFIEDTRIPLE join (the parts share a letter) still may not leave three equal + // letters in the word (`buss|ssen` as `busssen`, `boo|oob` as `booob`). + if c.triple && start + 1 == pe && s.chars.get(pe) == Some(&a) { + return false; + } if c.patterns.is_empty() { return true; } @@ -756,7 +804,8 @@ impl AffixDict { if s.c.dup && last.stem == prev.stem { return false; } - if last.marks & 1 != 0 && s.c.force_ucase.is_some() && !s.capitalized { + // FORCEUCASE: a word with no capital at all is wrong (`fooQuxbar` is right in the CLI). + if last.force && !s.capitalized && !s.chars.iter().any(|c| c.is_uppercase()) { return false; } let span = |s: &Search, i: usize, j: usize| -> String { @@ -1067,6 +1116,13 @@ mod tests { for w in wrong.split_whitespace() { assert!(!d.check(w), "{locale}: {w} is wrong"); } + // A long garbled word (wrong words run together): its suggestions stay quick. + let garbled: String = wrong.chars().filter(|c| !c.is_whitespace()).take(38).collect(); + let t = std::time::Instant::now(); + let _ = crate::affix_suggest::suggest_in(&d, &garbled, 8, &crate::affix_suggest::Tuning::DEFAULT); + let took = t.elapsed(); + eprintln!("{locale}: suggestions for {garbled} in {took:?}"); + assert!(took.as_secs() < 5, "{locale}: {took:?}"); } } @@ -1079,8 +1135,8 @@ mod tests { ), ( "de", - "Südwestbeifallsturm Machtfeudalideologie Schleifhärtering Gesprächskontur schule", - "chilisoßeswitttenaus Magmabewegungaminosäurebasis ausgezählterzehntagefieber heerfolgetemperatechnik haus buch", + "Südwestbeifallsturm Machtfeudalideologie Schleifhärtering Gesprächskontur schule Verfilmungen Aufwärmungen", + "chilisoßeswitttenaus Magmabewegungaminosäurebasis ausgezählterzehntagefieber heerfolgetemperatechnik haus buch verfilmungs verfilmungen aufwärmungen", ), ( "de-at", @@ -1119,8 +1175,8 @@ mod tests { ("nn", "", "orstuvastavantgardistar påskemåndagarkjempekenguruar piggtrådmusikkarreknemaskinar stimreinselikniårekeiper"), ( "sv", - "målvärdenservername sunniglädjepirrets kedjelängdendöbattangerna basfärgmetropoliterna", - "ogenomskådligsbåtsmanstorps tännkarheiskanens stängningstaggensbehängas råseleedemonlägeouppvärmt", + "målvärdenservername sunniglädjepirrets kedjelängdendöbattangerna basfärgmetropoliterna Norges Sveriges norge", + "ogenomskådligsbåtsmanstorps tännkarheiskanens stängningstaggensbehängas råseleedemonlägeouppvärmt norges sveriges afrikas", ), ( "sv-fi", @@ -1129,6 +1185,36 @@ mod tests { ), ]; + /// craft-libs#497 review: hundreds of prefixes and suffixes that each add a part to the same + /// one-letter stem, with distinct pattern flags, make tens of thousands of ways for one + /// piece. They are capped and counted, so a word still answers at once. + #[test] + fn many_ways_for_one_piece_stay_cheap() { + let marks: Vec = (0..62).filter_map(|i| char::from_u32(0x3041 + i)).collect(); + let mut aff = String::from("SET UTF-8\nFLAG UTF-8\nCOMPOUNDFLAG X\nCOMPOUNDMIN 1\nCOMPOUNDPERMITFLAG P\nCOMPOUNDWORDMAX 99\n"); + let mut pats: Vec = marks.iter().map(|m| format!("zz/{m} zz/{m}")).collect(); + pats.push("b a".into()); + aff.push_str(&format!("CHECKCOMPOUNDPATTERN {}\n", pats.len())); + for p in &pats { + aff.push_str(&format!("CHECKCOMPOUNDPATTERN {p}\n")); + } + let mut flags = String::new(); + for (kind, base, shift) in [("PFX", 0x4e00, 0), ("SFX", 0x5e00, 31)] { + for i in 0..250u32 { + let Some(f) = char::from_u32(base + i) else { continue }; + let m: String = (0..31).filter(|b| (i >> b) & 1 == 1).filter_map(|b| marks.get(shift + b)).collect(); + aff.push_str(&format!("{kind} {f} Y 1\n{kind} {f} 0 a/XP{m} .\n")); + flags.push(f); + } + } + let d = AffixDict::from_hunspell(&aff, &format!("2\na/X{flags}\nb/X\n"), &[]).unwrap(); + let t = std::time::Instant::now(); + for w in ["baaa", "bbbbbbbbbbbbbbbbbbbbaaa", "ab"] { + let _ = d.check(w); + } + assert!(t.elapsed().as_millis() < 2000, "{:?}", t.elapsed()); + } + /// `COMPOUNDRULE` as the Swedish (`)k`) and Mongolian (`[a0,a1]`, `%?`, `.`) dictionaries /// write it, read as the CLI answers on the same rules. #[test] @@ -1194,7 +1280,11 @@ mod tests { let _ = d.check(w); slowest = slowest.max(t.elapsed()); } - eprintln!("hostile compounding: slowest word {slowest:?}"); + let t = std::time::Instant::now(); + let _ = crate::affix_suggest::suggest_in(&d, &"qwertyuiopasdfghjklzxcvbnm".repeat(2)[..38], 8, &crate::affix_suggest::Tuning::DEFAULT); + let suggest = t.elapsed(); + eprintln!("hostile compounding: slowest word {slowest:?}, suggestions {suggest:?}"); + assert!(suggest.as_secs() < 10, "{suggest:?}"); assert!(slowest.as_secs() < 5, "{slowest:?}"); } @@ -1477,6 +1567,144 @@ mod tests { right: false, listed: "abcdefghijkl abcdmnopijkl efghabcdijkl efghmnopabcd ijklabcdefgh ijklmnopabcd mnopabcdefgh", }, + Pinned { + name: "onlyincompound_homonyms", + aff: "COMPOUNDFLAG X\nONLYINCOMPOUND O\nSFX S Y 1\nSFX S 0 s .\nSFX T Y 1\nSFX T 0 t .", + dic: "foo/XOS foo/T bar/X baz/XO baz/XT", + pieces: "foo foos foot bar baz bazt bazs", + extra: "", + right: true, + listed: "foo foot bar baz bazt foofoo foofoos foobar foobaz foobazt barfoo barfoos barbar barbaz barbazt bazfoo bazfoos \ + bazbar bazbaz bazbazt foofoofoo foofoofoos foofoobar foofoobaz foofoobazt foobarfoo foobarfoos foobarbar \ + foobarbaz foobarbazt foobazfoo foobazfoos foobazbar foobazbaz foobazbazt barfoofoo barfoofoos barfoobar \ + barfoobaz barfoobazt barbarfoo barbarfoos barbarbar barbarbaz barbarbazt barbazfoo barbazfoos barbazbar \ + barbazbaz barbazbazt bazfoofoo bazfoofoos bazfoobar bazfoobaz bazfoobazt bazbarfoo bazbarfoos bazbarbar \ + bazbarbaz bazbarbazt bazbazfoo bazbazfoos bazbazbar bazbazbaz bazbazbazt", + }, + Pinned { + name: "onlyincompound_homonyms_alone", + aff: "ONLYINCOMPOUND O\nNEEDAFFIX N\nSFX S Y 1\nSFX S 0 s .\nPFX U Y 1\nPFX U 0 un .", + dic: "bar/OS bar/U qux/O qux/S zed/OS", + pieces: "", + extra: "bar bars unbar qux quxs unqux zed zeds", + right: true, + listed: "bar unbar qux quxs", + }, + Pinned { + name: "simplified_triple_checked", + aff: "COMPOUNDFLAG X\nCHECKCOMPOUNDTRIPLE\nSIMPLIFIEDTRIPLE", + dic: "boo/X oob/X ob/X xaa/X aay/X ay/X schiff/X fahrt/X ffahrt/X buss/X ssen/X fjell/X llama/X", + pieces: "", + extra: "booob boob boooob boobob xaaay xaay xaaaay schiffahrt schifffahrt schiffffahrt booobboo boobboo busssen bussen fjelllama fjellama", + right: true, + listed: "schiffahrt", + }, + Pinned { + name: "simplified_triple_min", + aff: "COMPOUNDFLAG X\nCOMPOUNDMIN 3\nCHECKCOMPOUNDTRIPLE\nSIMPLIFIEDTRIPLE", + dic: "foo/X ooa/X oo/X bb/X bbb/X abb/X bba/X", + pieces: "foo ooa oo abb bba bbb", + extra: "fooa abba abbba abbbba", + right: true, + listed: "foo ooa oo abb bba bbb foofoo fooabb foobba foobbb ooafoo ooaooa ooaabb ooabba ooabbb abbfoo abbooa abbabb \ + bbafoo bbaooa bbaabb bbabba bbabbb bbbfoo bbbooa bbbabb foofoofoo foofooabb foofoobba foofoobbb fooabbfoo \ + fooabbooa fooabbabb foobbafoo foobbaooa foobbaabb foobbabba foobbabbb foobbbfoo foobbbooa foobbbabb ooafoofoo \ + ooafooabb ooafoobba ooafoobbb ooaooafoo ooaooaooa ooaooaabb ooaooabba ooaooabbb ooaabbfoo ooaabbooa ooaabbabb \ + ooabbafoo ooabbaooa ooabbaabb ooabbabba ooabbabbb ooabbbfoo ooabbbooa ooabbbabb abbfoofoo abbfooabb abbfoobba \ + abbfoobbb abbooafoo abbooaooa abbooaabb abbooabba abbooabbb abbabbfoo abbabbooa abbabbabb bbafoofoo bbafooabb \ + bbafoobba bbafoobbb bbaooafoo bbaooaooa bbaooaabb bbaooabba bbaooabbb bbaabbfoo bbaabbooa bbaabbabb bbabbafoo \ + bbabbaooa bbabbaabb bbabbabba bbabbabbb bbabbbfoo bbabbbooa bbabbbabb bbbfoofoo bbbfooabb bbbfoobba bbbfoobbb \ + bbbooafoo bbbooaooa bbbooaabb bbbooabba bbbooabbb bbbabbfoo bbbabbooa bbbabbabb", + }, + Pinned { + name: "keepcase_parts", + aff: "COMPOUNDFLAG X\nKEEPCASE K", + dic: "foo/X baz/XK bar/X", + pieces: "foo baz bar Foo Baz BAZ FOO", + extra: "", + right: true, + listed: "foo baz bar Foo FOO foofoo foobaz foobar bazfoo bazbaz bazbar barfoo barbaz barbar Foofoo Foobaz Foobar FOOBAZ \ + FOOFOO foofoofoo foofoobaz foofoobar foobazfoo foobazbaz foobazbar foobarfoo foobarbaz foobarbar bazfoofoo \ + bazfoobaz bazfoobar bazbazfoo bazbazbaz bazbazbar bazbarfoo bazbarbaz bazbarbar barfoofoo barfoobaz barfoobar \ + barbazfoo barbazbaz barbazbar barbarfoo barbarbaz barbarbar Foofoofoo Foofoobaz Foofoobar Foobazfoo Foobazbaz \ + Foobazbar Foobarfoo Foobarbaz Foobarbar FOOBAZBAZ FOOBAZFOO FOOFOOBAZ FOOFOOFOO", + }, + Pinned { + name: "capitalized_stem_parts", + aff: "COMPOUNDFLAG X", + dic: "foo/X Qux/X bar/X", + pieces: "foo Qux qux bar Foo QUX", + extra: "", + right: true, + listed: "foo Qux bar Foo QUX foofoo fooQux foobar Quxfoo QuxQux Quxbar barfoo barQux barbar Foofoo FooQux Foobar \ + foofoofoo foofooQux foofoobar fooQuxfoo fooQuxQux fooQuxbar foobarfoo foobarQux foobarbar Quxfoofoo QuxfooQux \ + Quxfoobar QuxQuxfoo QuxQuxQux QuxQuxbar Quxbarfoo QuxbarQux Quxbarbar barfoofoo barfooQux barfoobar barQuxfoo \ + barQuxQux barQuxbar barbarfoo barbarQux barbarbar Foofoofoo FoofooQux Foofoobar FooQuxfoo FooQuxQux FooQuxbar \ + Foobarfoo FoobarQux Foobarbar", + }, + Pinned { + name: "forceucase_mixed", + aff: "COMPOUNDFLAG X\nFORCEUCASE U\nKEEPCASE K", + dic: "foo/X bar/XU baz/XK Qux/X", + pieces: "foo bar baz Foo Bar Qux", + extra: "FOOBAR FooBar fooBar FOOBAZ Foobaz QuxBar Quxbar QUXBAR", + right: true, + listed: "foo bar baz Foo Bar Qux foofoo foobaz fooQux barfoo barbaz barQux bazfoo bazbaz bazQux Foofoo Foobar Foobaz \ + FooQux Barfoo Barbar Barbaz BarQux Quxfoo Quxbar Quxbaz QuxQux foofoofoo foofoobaz foofooQux foobarfoo \ + foobarbaz foobarQux foobazfoo foobazbaz foobazQux fooQuxfoo fooQuxbar fooQuxbaz fooQuxQux barfoofoo barfoobaz \ + barfooQux barbarfoo barbarbaz barbarQux barbazfoo barbazbaz barbazQux barQuxfoo barQuxbar barQuxbaz barQuxQux \ + bazfoofoo bazfoobaz bazfooQux bazbarfoo bazbarbaz bazbarQux bazbazfoo bazbazbaz bazbazQux bazQuxfoo bazQuxbar \ + bazQuxbaz bazQuxQux Foofoofoo Foofoobar Foofoobaz FoofooQux Foobarfoo Foobarbar Foobarbaz FoobarQux Foobazfoo \ + Foobazbar Foobazbaz FoobazQux FooQuxfoo FooQuxbar FooQuxbaz FooQuxQux Barfoofoo Barfoobar Barfoobaz BarfooQux \ + Barbarfoo Barbarbar Barbarbaz BarbarQux Barbazfoo Barbazbar Barbazbaz BarbazQux BarQuxfoo BarQuxbar BarQuxbaz \ + BarQuxQux Quxfoofoo Quxfoobar Quxfoobaz QuxfooQux Quxbarfoo Quxbarbar Quxbarbaz QuxbarQux Quxbazfoo Quxbazbar \ + Quxbazbaz QuxbazQux QuxQuxfoo QuxQuxbar QuxQuxbaz QuxQuxQux FOOBAR FOOBAZ QUXBAR", + }, + Pinned { + name: "forceucase_suffix", + aff: "COMPOUNDFLAG X\nFORCEUCASE U\nSFX S Y 1\nSFX S 0 s/U .", + dic: "foo/X baz/XS", + pieces: "foo baz bazs", + extra: "Foobazs", + right: false, + listed: "bazsfoo bazsbaz bazsbazs foobazsfoo foobazsbaz foobazsbazs bazbazsfoo bazbazsbaz bazbazsbazs bazsfoofoo \ + bazsfoobaz bazsfoobazs bazsbazfoo bazsbazbaz bazsbazbazs bazsbazsfoo bazsbazsbaz bazsbazsbazs", + }, + Pinned { + name: "pattern_unsuffixed", + aff: "COMPOUNDFLAG X\nCOMPOUNDPERMITFLAG P\nSFX S Y 1\nSFX S 0 s/P .\nPFX U Y 1\nPFX U 0 un/P .\nCHECKCOMPOUNDPATTERN 2\nCHECKCOMPOUNDPATTERN 0 b\nCHECKCOMPOUNDPATTERN 0 un", + dic: "foo/XSU bar/XSU oof/X", + pieces: "foo foos unfoo bar bars unbar oof", + extra: "", + right: true, + listed: "foo foos unfoo bar bars unbar oof foofoo foofoos foooof foosfoo foosfoos foosunfoo foosbar foosbars foosunbar \ + foosoof unfoofoo unfoofoos unfoooof barfoo barfoos baroof barsfoo barsfoos barsunfoo barsbar barsbars \ + barsunbar barsoof unbarfoo unbarfoos unbaroof ooffoo ooffoos oofoof foofoofoo foofoofoos foofoooof foofoosfoo \ + foofoosfoos foofoosunfoo foofoosbar foofoosbars foofoosunbar foofoosoof fooooffoo fooooffoos foooofoof \ + foosfoofoo foosfoofoos foosfoooof foosfoosfoo foosfoosfoos foosfoosunfoo foosfoosbar foosfoosbars \ + foosfoosunbar foosfoosoof foosunfoofoo foosunfoofoos foosunfoooof foosbarfoo foosbarfoos foosbaroof \ + foosbarsfoo foosbarsfoos foosbarsunfoo foosbarsbar foosbarsbars foosbarsunbar foosbarsoof foosunbarfoo \ + foosunbarfoos foosunbaroof foosooffoo foosooffoos foosoofoof unfoofoofoo unfoofoofoos unfoofoooof unfoofoosfoo \ + unfoofoosfoos unfoofoosunfoo unfoofoosbar unfoofoosbars unfoofoosunbar unfoofoosoof unfooooffoo unfooooffoos \ + unfoooofoof barfoofoo barfoofoos barfoooof barfoosfoo barfoosfoos barfoosunfoo barfoosbar barfoosbars \ + barfoosunbar barfoosoof barooffoo barooffoos baroofoof barsfoofoo barsfoofoos barsfoooof barsfoosfoo \ + barsfoosfoos barsfoosunfoo barsfoosbar barsfoosbars barsfoosunbar barsfoosoof barsunfoofoo barsunfoofoos \ + barsunfoooof barsbarfoo barsbarfoos barsbaroof barsbarsfoo barsbarsfoos barsbarsunfoo barsbarsbar barsbarsbars \ + barsbarsunbar barsbarsoof barsunbarfoo barsunbarfoos barsunbaroof barsooffoo barsooffoos barsoofoof \ + unbarfoofoo unbarfoofoos unbarfoooof unbarfoosfoo unbarfoosfoos unbarfoosunfoo unbarfoosbar unbarfoosbars \ + unbarfoosunbar unbarfoosoof unbarooffoo unbarooffoos unbaroofoof ooffoofoo ooffoofoos ooffoooof ooffoosfoo \ + ooffoosfoos ooffoosunfoo ooffoosbar ooffoosbars ooffoosunbar ooffoosoof oofooffoo oofooffoos oofoofoof", + }, + Pinned { + name: "forbidflag_prefix", + aff: "COMPOUNDFLAG X\nCOMPOUNDPERMITFLAG P\nCOMPOUNDFORBIDFLAG Q\nPFX C Y 1\nPFX C 0 de/PQ .", + dic: "foo/XC bar/X", + pieces: "foo defoo bar", + extra: "", + right: true, + listed: "foo defoo bar foofoo foobar barfoo barbar foofoofoo foofoobar foobarfoo foobarbar barfoofoo barfoobar \ + barbarfoo barbarbar", + }, Pinned { name: "forbidden", aff: "COMPOUNDFLAG X\nFORBIDDENWORD F\nSFX S Y 1\nSFX S 0 en .", diff --git a/crates/proof/src/affix_suggest.rs b/crates/proof/src/affix_suggest.rs index 3c9b06bcf..7d4882098 100644 --- a/crates/proof/src/affix_suggest.rs +++ b/crates/proof/src/affix_suggest.rs @@ -125,6 +125,8 @@ struct Collector<'a> { names: bool, /// May half of a missing space be one ([`Tuning::strict_case`])? split_names: bool, + /// Candidates checked with compounds so far (at most `MAX_SUGGEST_COMPOUNDS`). + compound_checks: usize, } impl Collector<'_> { @@ -144,14 +146,16 @@ impl Collector<'_> { let mut words = Vec::new(); let mut cost = cost; let names = if cand.contains(' ') { self.split_names } else { self.names }; + let compounds = self.d.flag_compounds() && self.compound_checks < crate::affix::MAX_SUGGEST_COMPOUNDS; + self.compound_checks += usize::from(compounds); for part in cand.split(' ') { - if self.d.contains_suggestible(part) { + if self.d.contains_suggestible_with(part, compounds) { words.push(part.to_string()); } else if !names { return; } else { let c = capitalize(part); - if !self.d.contains_suggestible(&c) { + if !self.d.contains_suggestible_with(&c, compounds) { return; } words.push(c); @@ -308,7 +312,8 @@ pub(crate) fn suggest_in(d: &AffixDict, word: &str, max: usize, t: &Tuning) -> V let chars: Vec = input.chars().collect(); let names = !t.strict_case || case != Casing::Lower; let split_names = !t.strict_case; - let mut col = Collector { d, input: input.clone(), tested: HashMap::new(), found: HashMap::new(), seq: 0, names, split_names }; + let mut col = + Collector { d, input: input.clone(), tested: HashMap::new(), found: HashMap::new(), seq: 0, names, split_names, compound_checks: 0 }; add_case(&mut col, word, &input); let map = groups(d, t); add_map_slips(&mut col, &chars, &map, t); @@ -640,8 +645,16 @@ mod tests { #[test] fn a_cheaper_later_offer_wins() { let d = AffixDict::from_hunspell("", "1\ncafe\n", &[]).unwrap(); - let mut col = - Collector { d: &d, input: "cafè".into(), tested: HashMap::new(), found: HashMap::new(), seq: 0, names: true, split_names: true }; + let mut col = Collector { + d: &d, + input: "cafè".into(), + tested: HashMap::new(), + found: HashMap::new(), + seq: 0, + names: true, + split_names: true, + compound_checks: 0, + }; col.offer("cafe", COST_DROP_DIACRITIC); col.offer("cafe", COST_REP); col.offer("cafe", COST_SCAN);