mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-08 19:42:12 +00:00
Merge pull request #488 from benandez/p6-affix
craft-proof: NEEDAFFIX, CIRCUMFIX and FULLSTRIP (port of wordcraft#5012's engine part)
This commit is contained in:
1 file changed
+542
-56
+542
-56
@@ -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<u16>,
|
||||
/// `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<u16>,
|
||||
/// `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<u16>,
|
||||
/// `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<FlagKey>,
|
||||
/// The `KEEPCASE` flag.
|
||||
keepcase: Option<FlagKey>,
|
||||
need_affix: Option<FlagKey>,
|
||||
circumfix: Option<FlagKey>,
|
||||
full_strip: bool,
|
||||
iconv: Vec<(String, String)>,
|
||||
ignore: Vec<char>,
|
||||
/// `COMPOUNDRULE` patterns, as written (`n*1t`); read in the `FLAG` mode by
|
||||
@@ -476,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<String> {
|
||||
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() }
|
||||
@@ -621,9 +635,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<u16>; 4]);
|
||||
struct Excluded([Option<u16>; 5]);
|
||||
|
||||
impl Excluded {
|
||||
/// Does the flag set `set` carry an excluded flag?
|
||||
@@ -756,6 +771,9 @@ fn parse_aff(aff: &str, english: bool) -> Result<Rules, String> {
|
||||
let min = at(1).parse::<usize>().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 +957,10 @@ impl AffixDict {
|
||||
let mut distinct: FxMap<FlagKey, u16> = 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 +985,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(),
|
||||
@@ -998,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
|
||||
@@ -1014,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));
|
||||
@@ -1035,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;
|
||||
@@ -1254,6 +1285,11 @@ impl AffixDict {
|
||||
None
|
||||
}
|
||||
|
||||
/// Do the continuation classes of `r` hold `flag`?
|
||||
fn cont_holds(&self, r: &Rule, flag: Option<u16>) -> 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 +1316,7 @@ impl AffixDict {
|
||||
// total size of the distinct sets it meets, not rules × set size.
|
||||
let probe = FlagProbe::new(flags);
|
||||
let mut seen: FxMap<u32, bool> = 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 +1327,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 +1389,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, 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 +1405,19 @@ 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);
|
||||
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;
|
||||
}
|
||||
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, needs, excluded) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1394,7 +1437,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<usize> = w.char_indices().map(|(i, _)| i).chain(std::iter::once(w.len())).collect();
|
||||
let n = cuts.len();
|
||||
@@ -1410,20 +1455,27 @@ 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))
|
||||
}
|
||||
|
||||
/// 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<u16>, excluded: Excluded) -> bool {
|
||||
/// 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<u16>, 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<u16> = 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 };
|
||||
@@ -1435,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) && 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;
|
||||
}
|
||||
@@ -1446,7 +1503,9 @@ 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.
|
||||
// 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();
|
||||
@@ -1500,8 +1559,14 @@ impl AffixDict {
|
||||
/// cost millions of strings (#3613).
|
||||
pub fn forms(&self, stem: &str) -> Vec<String> {
|
||||
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: 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();
|
||||
@@ -1512,14 +1577,23 @@ impl AffixDict {
|
||||
continue;
|
||||
}
|
||||
let Some(base) = stem.strip_suffix(&*r.strip) else { continue };
|
||||
let form = format!("{base}{}", r.add);
|
||||
if r.cross {
|
||||
cross_sfx.push(form.clone());
|
||||
if base.is_empty() && !self.full_strip {
|
||||
continue;
|
||||
}
|
||||
if r.cont != 0 && queued.insert((form.clone(), r.cont)) {
|
||||
let form = format!("{base}{}", r.add);
|
||||
let circ = self.cont_holds(r, self.circumfix);
|
||||
if r.cross {
|
||||
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)) {
|
||||
chain.push((form.clone(), r.cont));
|
||||
}
|
||||
out.push(form);
|
||||
// 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.extend(non_empty(form));
|
||||
}
|
||||
if out.len() >= MAX_FORMS {
|
||||
return out;
|
||||
}
|
||||
@@ -1541,11 +1615,16 @@ 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);
|
||||
// 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;
|
||||
}
|
||||
@@ -1553,20 +1632,25 @@ impl AffixDict {
|
||||
}
|
||||
}
|
||||
for r in &self.prefixes {
|
||||
if !has(f, r.flag) || !r.cond_matches(stem, false) {
|
||||
continue;
|
||||
let on_stem = has(f, r.flag);
|
||||
let circ = self.cont_holds(r, self.circumfix);
|
||||
let needs = self.cont_holds(r, self.need_affix);
|
||||
if on_stem && !circ && !needs {
|
||||
out.extend(self.prefixed(r, stem));
|
||||
}
|
||||
let Some(rest) = stem.strip_prefix(&*r.strip) else { continue };
|
||||
out.push(format!("{}{rest}", r.add));
|
||||
if r.cross {
|
||||
for s in &cross_sfx {
|
||||
// Every suffix form looked at counts against the work budget, matching or not.
|
||||
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 let Some(rest) = s.strip_prefix(&*r.strip) {
|
||||
out.push(format!("{}{rest}", r.add));
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
if out.len() >= MAX_FORMS {
|
||||
@@ -1576,6 +1660,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<String> {
|
||||
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<u32> {
|
||||
let mut rules: Vec<u32> = Vec::new();
|
||||
@@ -1654,9 +1749,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 +1857,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())),
|
||||
@@ -2429,6 +2535,217 @@ 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"));
|
||||
}
|
||||
|
||||
/// 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<String> = 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"] {
|
||||
@@ -2480,9 +2797,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.
|
||||
@@ -2925,6 +3240,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"] {
|
||||
@@ -2952,4 +3282,160 @@ 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::<Vec<_>>() {
|
||||
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"));
|
||||
}
|
||||
|
||||
/// 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| {
|
||||
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");
|
||||
// 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");
|
||||
// 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"]);
|
||||
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"));
|
||||
}
|
||||
|
||||
/// `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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 x\n";
|
||||
let d = AffixDict::from_hunspell(aff, "1\nx/AP\n", &[]).unwrap();
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user