mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-15 06:52:23 +00:00
CP7.3: craft-libs#497 review — compounds as the hunspell CLI answers: ONLYINCOMPOUND homonyms keep their flags, triples after SIMPLIFIEDTRIPLE, KEEPCASE and capitals in parts, CHECKCOMPOUNDPATTERN 0, a forbidden prefix, FORCEUCASE; suggestions and many-way pieces stay cheap
- ONLYINCOMPOUND homonyms beside others are kept apart (compound parts only), so their suffixes make no standalone words (de verfilmungs, sv norges); round-trips as a second stem line. - CHECKCOMPOUNDTRIPLE also checks a SIMPLIFIEDTRIPLE join (busssen, booob wrong). - KEEPCASE holds for the first part only; a capitalized stem is a part (FooQux). - CHECKCOMPOUNDPATTERN `0`: no suffix (a prefix doesn't count): unfoobar wrong. - COMPOUNDFORBIDFLAG on a prefix: no part anywhere (bardefoo wrong). - FORCEUCASE: from the stem only; wrong only without any capital (fooQuxbar right). - Suggestion candidates: 2,048 compound steps each, compounds for the first 128 only. - A piece's ways are hashed, capped at 256 and counted (with the patterns) against the budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
This commit is contained in:
1 parent
9a442e7048
commit
3eaa276786
4 files changed
+474
-89
No files matched your search
@@ -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 <path without extension> -l` on the same words
|
||||
@@ -83,6 +85,36 @@ fn generate(d: &AffixDict, n: usize, seed: u64) -> Vec<String> {
|
||||
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<String> {
|
||||
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<String> = 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<String> = 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 <aff> <dic> < words | affix_check gen <aff> <dic> <n> <seed>".into()),
|
||||
_ => Err("usage: affix_check check <aff> <dic> < words | affix_check gen|single <aff> <dic> <n> <seed>".into()),
|
||||
}
|
||||
}
|
||||
+147
-36
@@ -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<char>,
|
||||
/// Flag-based compounding (`COMPOUNDFLAG`…), if the dictionary compounds by flags.
|
||||
cpd: Option<Box<compound::Compounding>>,
|
||||
/// Stems with `ONLYINCOMPOUND` homonyms beside others: stem index → those homonyms' flags
|
||||
/// (compound parts only), while `flags` holds the others'.
|
||||
cpd_sets: FxMap<u32, u32>,
|
||||
/// Those stems' sets with all their homonyms' flags, for `COMPOUNDRULE` parts.
|
||||
rule_sets: FxMap<u32, u32>,
|
||||
/// Appended string → the prefix rules adding it, in rule order.
|
||||
pfx_index: FxMap<Box<str>, Vec<u32>>,
|
||||
/// 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<u16>; 5], bool);
|
||||
struct Excluded([Option<u16>; 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<u16>) -> Excluded {
|
||||
for slot in &mut self.0 {
|
||||
if *slot == flag {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// These exclusions and `flag` too.
|
||||
fn and(mut self, flag: Option<u16>) -> 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<u16>, fresh: &[u16]) -> bool {
|
||||
let before = set.len();
|
||||
let add: Vec<u16> = 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<u16>, 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<u16> = Vec::new();
|
||||
let mut solo: Vec<u16> = Vec::new();
|
||||
let mut only_set: Vec<u16> = Vec::new();
|
||||
let mut fresh: Vec<u16> = Vec::new();
|
||||
let mut carried: Vec<u16> = 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.
|
||||
|
||||
@@ -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<usize>,
|
||||
chars: Vec<char>,
|
||||
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<Option<(u32, u32)>>,
|
||||
@@ -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<usize> = w.char_indices().map(|(i, _)| i).chain(std::iter::once(w.len())).collect();
|
||||
let chars: Vec<char> = 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<Info, std::hash::BuildHasherDefault<crate::patterns::FxHasher>> = Default::default();
|
||||
let mut found: Vec<Info> = 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<Info> {
|
||||
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<u16>| 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<Item = (u32, &[u16])> {
|
||||
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<char> = (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<String> = 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 .",
|
||||
|
||||
@@ -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<char> = 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);
|
||||
|
||||
Reference in new issue
Block a user