Browse / github.com/storytold/wordcraft / patch
proof: flag aliases, single-byte flags, ICONV, IGNORE and KEEPCASE in affix dictionaries (#735)
proof: flag aliases, single-byte flags, ICONV, IGNORE and KEEPCASE in affix dictionaries (#735) Supersedes #1859 (same commits, rebased on current `main`; the review history is there). First of three steps for Hungarian spell checking (#735); compounding (#2070) and the Hungarian module (#2385) follow once this lands. ## What changes The Hungarian Hunspell dictionary (`hu_HU`, Magyar Ispell) uses affix-file features the engine didn't read. This PR adds them; compounding and `NEEDAFFIX` stay refused, so `hu_HU` doesn't load yet. **Reading the files** (`hunspell.rs`) - Flags are read byte by byte when the `.aff` has no `FLAG` line or `FLAG DEFAULT`, as Hunspell does: `hu_HU` says `SET U
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Skaywalker (imported from pull request #4575, open)
- Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750- Size
- +835 −113 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
aa49536a3e0868c8a85201f0
How it went
No other operator has reported using it yet.
Diff
raw · crates/proof/src/affix.rs crates/proof/src/hunspell.rs
@@ -11,10 +11,14 @@ //! also be stored in WordCraft's compact format ([`AffixDict::to_bytes`]): the rules as text //! lines and the stems front-coded, raw-deflated together (the Polish dictionary is under 1 MB). //!-//! Supported: one-character flags (at most 128 distinct, any Unicode character — `FLAG UTF-8`),-//! `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`, and the suggestion tables `TRY`, `KEY`, `MAP` and `REP`. Rejected: compounding,+//! Supported: one-character flags (at most 256 distinct, any Unicode character — `FLAG UTF-8`),+//! 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`.+//! Morphological data (`AM` aliases, the fields after a stem) is skipped. Rejected: compounding, //! long or numeric flags, `NEEDAFFIX` and the other directives that change which words are correct. //! //! Lookups never panic: stems sit in one string, found through an open-addressing hash table, and@@ -33,30 +37,91 @@ const EMPTY: u32 = u32::MAX; /// Directives that change which words are correct in ways this engine doesn't implement. const UNSUPPORTED: &[&str] = &[- "AF",- "AM", "CHECKSHARPS", "CIRCUMFIX", "COMPLEXPREFIXES", "COMPOUNDBEGIN", "COMPOUNDEND",+ "COMPOUNDFIRST", "COMPOUNDFLAG",+ "COMPOUNDLAST", "COMPOUNDMIDDLE", "COMPOUNDMIN", "COMPOUNDPERMITFLAG", "COMPOUNDRULE", "FULLSTRIP",- "ICONV",- "IGNORE",- "KEEPCASE",- "LEMMA_PRESENT", "NEEDAFFIX", "OCONV", "ONLYINCOMPOUND", "PSEUDOROOT",- "SUBSTANDARD", ]; +/// Most distinct flags a dictionary may use: every single-byte flag (the Hungarian dictionary+/// uses 158).+const MAX_FLAGS: usize = 256;++/// A set of flags, one bit per distinct flag character of the dictionary.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]+struct Mask([u64; MAX_FLAGS / 64]);++impl Mask {+ const EMPTY: Mask = Mask([0; MAX_FLAGS / 64]);++ /// The set holding flag number `i` alone (empty when `i` is out of range).+ fn bit(i: usize) -> Mask {+ let mut m = Mask::EMPTY;+ if let Some(w) = m.0.get_mut(i / 64) {+ *w = 1 << (i % 64);+ }+ m+ }++ /// The number of the lowest flag in the set, if any.+ fn first(self) -> Option<usize> {+ self.0.iter().enumerate().find(|(_, w)| **w != 0).map(|(i, w)| i * 64 + w.trailing_zeros() as usize)+ }++ fn is_empty(self) -> bool {+ self.0.iter().all(|&w| w == 0)+ }++ /// Do the two sets share a flag?+ fn meets(self, other: Mask) -> bool {+ !(self & other).is_empty()+ }++ /// Does this set hold every flag of `other`?+ fn covers(self, other: Mask) -> bool {+ (self & other) == other+ }+}++impl std::ops::BitAnd for Mask {+ type Output = Mask;+ fn bitand(mut self, o: Mask) -> Mask {+ for (a, b) in self.0.iter_mut().zip(o.0) {+ *a &= b;+ }+ self+ }+}++impl std::ops::BitOr for Mask {+ type Output = Mask;+ fn bitor(mut self, o: Mask) -> Mask {+ self |= o;+ self+ }+}++impl std::ops::BitOrAssign for Mask {+ fn bitor_assign(&mut self, o: Mask) {+ for (a, b) in self.0.iter_mut().zip(o.0) {+ *a |= b;+ }+ }+}+ /// One character class of a rule's condition. #[derive(Clone, Debug, PartialEq, Eq)] enum Cond {@@ -130,14 +195,14 @@ const MAX_FORMS_WORK: usize = 1 << 18; #[derive(Clone, Debug)] struct Rule { /// Bit of the rule's flag in the stems' flag masks.- bit: u128,+ bit: Mask, flag: char, cross: bool, strip: Box<str>, add: Box<str>, /// Continuation classes (`SFX A 0 able/B`): flags the derived form carries, licensing /// further affixes. Chained application is bounded by [`MAX_CHAIN`].- cont: u128,+ cont: Mask, cond: Box<[Cond]>, cond_text: Box<str>, }@@ -174,11 +239,13 @@ pub struct AffixDict { /// Per stem: index into `sets`. flags: Vec<u16>, /// Distinct flag masks.- sets: Vec<u128>,+ sets: Vec<Mask>, /// The `FORBIDDENWORD` flag's bit; stems carrying it are not words (0: none declared).- forbidden: u128,+ forbidden: Mask, /// Stems with NOSUGGEST are correct, but none of their forms are suggested.- no_suggest: u128,+ no_suggest: Mask,+ /// Stems with SUBSTANDARD are correct too, and likewise never suggested.+ substandard: Mask, /// Open-addressing hash table of stem indices (`EMPTY` = free). table: Vec<u32>, /// Flag char of each bit.@@ -195,6 +262,12 @@ pub struct AffixDict { key: String, map: Vec<Vec<char>>, rep: Vec<(String, String)>,+ /// The `KEEPCASE` flag's bit: such stems are words only in their own letter case (0: none).+ keepcase: Mask,+ /// `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).+ ignore: Box<[char]>, /// Provenance and licence lines, kept through [`AffixDict::to_bytes`]. comments: Vec<String>, }@@ -263,6 +336,57 @@ struct Rules { /// The `FORBIDDENWORD` flag: stems carrying it are listed but never correct. forbidden: Option<char>, no_suggest: Option<char>,+ substandard: Option<char>,+ /// The `KEEPCASE` flag.+ keepcase: Option<char>,+ iconv: Vec<(String, String)>,+ ignore: Vec<char>,+ /// `AF` flag aliases: alias `n` (1-based) stands for `aliases[n - 1]`.+ aliases: Vec<String>,+ /// The `AF` table opened with its count line, so flag fields are alias numbers. A table+ /// without one is rejected, as in Hunspell: its lines are ignored.+ aliases_on: bool,+}++impl Rules {+ /// Flags as written in a `.dic` entry or a continuation class: the flag characters+ /// themselves, or with an `AF` table the alias its leading digits number, as Hunspell reads+ /// them (`2x` is alias 2). A field without digits or an undefined alias reads as no flags:+ /// the stem is still a word, the rule still applies, without the flags (Hunspell warns).+ fn flags<'a>(&'a self, written: &'a str) -> &'a str {+ if !self.aliases_on {+ return written;+ }+ let digits = written.bytes().take_while(u8::is_ascii_digit).count();+ written+ .get(..digits)+ .and_then(|d| d.parse::<usize>().ok())+ .and_then(|n| n.checked_sub(1))+ .and_then(|i| self.aliases.get(i))+ .map_or("", String::as_str)+ }++ /// `w` without the `IGNORE` characters.+ fn strip_ignored(&self, w: &str) -> String {+ if self.ignore.is_empty() { w.to_string() } else { w.chars().filter(|c| !self.ignore.contains(c)).collect() }+ }+}++/// The fields of an `.aff`/`.dic` line: Hunspell separates them with spaces and tabs only, so a+/// flag byte read as U+0085 or U+00A0 (see [`crate::hunspell::decode`]) stays a flag.+fn split_fields(line: &str) -> impl Iterator<Item = &str> {+ line.split([' ', '\t']).filter(|f| !f.is_empty())+}++/// The one flag character a directive (`FORBIDDENWORD w`) names.+fn one_flag(arg: &str, dir: &str, n: usize) -> Result<char, String> {+ let mut it = arg.chars().filter(|&c| c != EMOJI_SELECTOR);+ match (it.next(), it.next()) {+ // Only the field separators of `split_fields` can't be a flag: a flag byte read as+ // U+0085 or U+00A0 is one.+ (Some(c), None) if !matches!(c, ' ' | '\t') => Ok(c),+ _ => Err(format!("line {}: {dir} needs one flag character", n + 1)),+ } } /// A rule as parsed from text, before flags become bits: `(flag, cross, strip, add, cont, cond)`.@@ -304,8 +428,10 @@ fn parse_aff(aff: &str) -> Result<Rules, String> { let mut cross: HashMap<(bool, char), bool> = HashMap::new(); let mut map_header = false; let mut rep_header = false;+ let mut af_header = false;+ let mut iconv_header = false; for (n, line) in aff.lines().enumerate() {- let f: Vec<&str> = line.split_whitespace().collect();+ let f: Vec<&str> = split_fields(line).collect(); let Some(&dir) = f.first() else { continue }; if dir.starts_with('#') { continue;@@ -314,25 +440,38 @@ fn parse_aff(aff: &str) -> Result<Rules, String> { match dir { "SET" | "LANG" | "NAME" | "VERSION" | "HOME" | "WORDCHARS" | "MAXNGRAMSUGS" | "MAXDIFF" | "ONLYMAXDIFF" | "NOSPLITSUGS" | "SUGSWITHDOTS" | "WARN" | "FORBIDWARN" | "MAXCPDSUGS" => {}+ // Morphological aliases, and a flag that only matters for morphological generation:+ // the words it marks are correct.+ "AM" | "LEMMA_PRESENT" => {}+ "SUBSTANDARD" => r.substandard = Some(one_flag(at(1), dir, n)?),+ // The first `AF` or `ICONV` line is the table's count, as in Hunspell, even with a+ // trailing comment (`AF 1559 # aliases`).+ "AF" => {+ if !af_header {+ af_header = true;+ // A count below 1 rejects the table too: flags stay literal (`AF 0`).+ r.aliases_on = at(1).parse::<usize>().is_ok_and(|n| n > 0);+ } else if r.aliases_on {+ r.aliases.push(at(1).to_string());+ }+ }+ "ICONV" => {+ if !iconv_header && at(1).parse::<usize>().is_ok() {+ iconv_header = true;+ } else if f.len() >= 3 {+ r.iconv.push((at(1).to_string(), at(2).to_string()));+ }+ }+ "IGNORE" => r.ignore = at(1).chars().collect(),+ "KEEPCASE" => r.keepcase = Some(one_flag(at(1), dir, n)?), // Per-character flag modes (`FLAG UTF-8`, as VERO's pt_BR dictionary uses) work // with the one-character parsing here; only the multi-character encodings differ. "FLAG" if !matches!(at(1), "UTF-8" | "DEFAULT" | "utf-8" | "default" | "UTF8" | "") => { return Err(format!("line {}: FLAG {} is not supported (one-character flags only)", n + 1, at(1))); } "FLAG" => {}- directive @ ("FORBIDDENWORD" | "NOSUGGEST") => {- let mut it = at(1).chars().filter(|&c| c != EMOJI_SELECTOR);- match (it.next(), it.next()) {- (Some(c), None) if !c.is_whitespace() => {- if directive == "FORBIDDENWORD" {- r.forbidden = Some(c);- } else {- r.no_suggest = Some(c);- }- }- _ => return Err(format!("line {}: {directive} needs one flag character", n + 1)),- }- }+ "FORBIDDENWORD" => r.forbidden = Some(one_flag(at(1), dir, n)?),+ "NOSUGGEST" => r.no_suggest = Some(one_flag(at(1), dir, n)?), "TRY" => r.try_chars = cap_try(at(1)), "KEY" => r.key = at(1).to_string(), "MAP" => {@@ -343,7 +482,7 @@ fn parse_aff(aff: &str) -> Result<Rules, String> { } } "REP" => {- if !rep_header && f.len() == 2 && at(1).parse::<usize>().is_ok() {+ if !rep_header && at(1).parse::<usize>().is_ok() { rep_header = true; } else if f.len() >= 3 { r.rep.push((at(1).replace('_', " "), at(2).replace('_', " ")));@@ -383,6 +522,19 @@ fn parse_aff(aff: &str) -> Result<Rules, String> { _ => {} } }+ // Aliases may be declared after the rules that use them; resolve once all are read.+ // Hunspell removes `IGNORE` characters from the appended text only: a stripped text that+ // holds one never matches a stem (stems lose them too), so the rule never applies.+ let resolve = |rules: &mut Vec<RawRule>, r: &Rules| {+ for rule in rules.iter_mut() {+ rule.4 = r.flags(&rule.4).to_string();+ rule.3 = r.strip_ignored(&rule.3);+ }+ };+ let (mut pfx, mut sfx) = (std::mem::take(&mut r.prefixes), std::mem::take(&mut r.suffixes));+ resolve(&mut pfx, &r);+ resolve(&mut sfx, &r);+ (r.prefixes, r.suffixes) = (pfx, sfx); Ok(r) } @@ -397,7 +549,12 @@ impl AffixDict { let rules = parse_aff(aff)?; let mut stems: Vec<(String, String)> = Vec::new(); for (n, line) in dic.lines().enumerate() {- let Some(tok) = line.split_whitespace().next() else { continue };+ // The entry may hold spaces (`ad hoc/B\tpo:adv`); it ends where Hunspell ends it.+ // Leading blanks belong to it, as in Hunspell (` lab` is no `lab`).+ let tok = line.get(..crate::hunspell::dic_entry_end(line.as_bytes())).unwrap_or_default();+ if tok.is_empty() {+ continue;+ } if n == 0 && tok.parse::<usize>().is_ok() { continue; }@@ -420,9 +577,11 @@ impl AffixDict { word.push(c); } }+ let word = rules.strip_ignored(&word); if word.is_empty() || word.len() > MAX_WORD_BYTES || word.contains(['\n', '/']) { continue; }+ let flags = rules.flags(&flags).to_string(); stems.push((word, flags)); } AffixDict::build(rules, stems, comments.iter().map(ToString::to_string).collect())@@ -434,11 +593,12 @@ impl AffixDict { flag_chars.extend(stems.iter().flat_map(|s| s.1.chars())); flag_chars.sort_unstable(); flag_chars.dedup();- if flag_chars.len() > 128 {- return Err(format!("{} distinct flags; at most 128 are supported", flag_chars.len()));+ if flag_chars.len() > MAX_FLAGS {+ return Err(format!("{} distinct flags; at most {MAX_FLAGS} are supported", flag_chars.len())); }- let bit_of = |c: char| flag_chars.iter().position(|&x| x == c).map_or(0, |i| 1u128 << i);- let forbidden = rules.forbidden.map_or(0, bit_of);+ let bit_of = |c: char| flag_chars.iter().position(|&x| x == c).map_or(Mask::EMPTY, Mask::bit);+ let forbidden = rules.forbidden.map_or(Mask::EMPTY, bit_of);+ let keepcase = rules.keepcase.map_or(Mask::EMPTY, bit_of); // Stems sorted by bytes; homonyms (the same word twice) are merged. stems.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes())); let mut d = AffixDict {@@ -446,7 +606,11 @@ impl AffixDict { offs: Vec::with_capacity(stems.len() + 1), flag_chars: flag_chars.clone(), forbidden,- no_suggest: rules.no_suggest.map_or(0, bit_of),+ no_suggest: rules.no_suggest.map_or(Mask::EMPTY, bit_of),+ substandard: rules.substandard.map_or(Mask::EMPTY, bit_of),+ keepcase,+ iconv: rules.iconv,+ ignore: rules.ignore.into_boxed_slice(), try_chars: rules.try_chars, key: rules.key, map: rules.map,@@ -455,16 +619,16 @@ impl AffixDict { ..AffixDict::default() }; d.offs.push(0);- let mut set_ids: HashMap<u128, u16> = HashMap::new();+ let mut set_ids: HashMap<Mask, u16> = HashMap::new(); let mut i = 0; while i < stems.len() { let Some((word, _)) = stems.get(i) else { break };- let mut mask = 0u128;+ let mut mask = Mask::EMPTY; let mut j = i; while let Some((w, f)) = stems.get(j) && w == word {- mask |= f.chars().map(bit_of).fold(0, |a, b| a | b);+ mask |= f.chars().map(bit_of).fold(Mask::EMPTY, |a, b| a | b); j += 1; } let next_id = u16::try_from(d.sets.len()).map_err(|_| "too many distinct flag sets".to_string())?;@@ -478,7 +642,7 @@ impl AffixDict { i = j; } let mk = |(flag, cross, strip, add, cont, cond): RawRule| -> Result<Rule, String> {- let cont = cont.chars().fold(0u128, |m, c| m | bit_of(c));+ let cont = cont.chars().fold(Mask::EMPTY, |m, c| m | bit_of(c)); Ok(Rule { bit: bit_of(flag), flag, cross, strip: strip.into(), add: add.into(), cont, cond: parse_cond(&cond)?, cond_text: cond.into() }) }; d.prefixes = rules.prefixes.into_iter().map(mk).collect::<Result<_, _>>()?;@@ -493,7 +657,7 @@ impl AffixDict { // Each (appended, stripped) pair's group, found by hash rather than by scanning the // appended string's groups: a dictionary with many strips per suffix stays linear. let mut group_of: FxMap<(&str, &str), usize> = FxMap::default();- let mut by_flag: Vec<Vec<u32>> = vec![Vec::new(); 128];+ let mut by_flag: Vec<Vec<u32>> = vec![Vec::new(); MAX_FLAGS]; for (i, r) in self.suffixes.iter().enumerate() { let groups = idx.entry(r.add.clone()).or_default(); let Ok(i) = u32::try_from(i) else { break };@@ -504,9 +668,7 @@ impl AffixDict { groups.push(SfxGroup { strip: r.strip.clone(), rules: vec![i] }); } }- if r.bit != 0- && let Some(list) = by_flag.get_mut(r.bit.trailing_zeros() as usize)- {+ if let Some(list) = r.bit.first().and_then(|b| by_flag.get_mut(b)) { list.push(i); } }@@ -551,12 +713,12 @@ impl AffixDict { } /// The flag characters of `mask`, in flag order (how the compact format stores flags).- fn flag_chars_for(&self, mask: u128) -> String {- self.flag_chars.iter().enumerate().filter(|&(i, _)| mask & (1u128 << i) != 0).map(|(_, c)| c).collect()+ fn flag_chars_for(&self, mask: Mask) -> String {+ self.flag_chars.iter().enumerate().filter(|&(i, _)| mask.meets(Mask::bit(i))).map(|(_, c)| c).collect() } /// The character of `mask` when it holds exactly one flag.- fn flag_char(&self, mask: u128) -> Option<char> {+ fn flag_char(&self, mask: Mask) -> Option<char> { let s = self.flag_chars_for(mask); let mut it = s.chars(); match (it.next(), it.next()) {@@ -589,11 +751,12 @@ impl AffixDict { } /// Flag mask of a stem, if `w` is one.- fn stem_flags(&self, w: &str) -> Option<u128> {- self.stem_flags_excluding(w, self.forbidden)+ fn stem_flags(&self, w: &str) -> Option<Mask> {+ self.stem_flags_except(w, Mask::EMPTY) } - fn stem_flags_excluding(&self, w: &str, excluded: u128) -> Option<u128> {+ /// Flag mask of a stem, if `w` is one and carries none of the flags in `deny`.+ fn stem_flags_except(&self, w: &str, deny: Mask) -> Option<Mask> { if self.table.is_empty() { return None; }@@ -607,8 +770,9 @@ impl AffixDict { if self.word(t as usize) == Some(w) { let id = *self.flags.get(t as usize)?; let mask = self.sets.get(usize::from(id)).copied();- // All derivations use this lookup: exclusions apply to stems and their forms.- return mask.filter(|m| m & excluded == 0);+ // A stem carrying FORBIDDENWORD is listed but is not a word: no direct hit,+ // and (this is the only stem lookup) no affixed form is built on it either.+ return mask.filter(|m| !m.meets(self.forbidden | deny)); } slot = (slot + 1) & mask; }@@ -621,8 +785,8 @@ impl AffixDict { /// The rules of a group that could lead to `w` are merged into one mask of their flags and /// followed once, not once per rule: the answer is the same, but thousands of rules of one /// flag cost one walk, not one each (#3613).- fn form_has_flag(&self, w: &str, flags: u128, depth: u8, excluded: u128) -> bool {- if self.stem_flags_excluding(w, excluded).is_some_and(|f| f & flags != 0) {+ fn form_has_flag(&self, w: &str, flags: Mask, depth: u8, deny: Mask) -> bool {+ if self.stem_flags_except(w, deny).is_some_and(|f| f.meets(flags)) { return true; } if depth == 0 {@@ -641,9 +805,9 @@ impl AffixDict { .rules .iter() .filter_map(|&i| self.suffixes.get(i as usize))- .filter(|r| r.cont & flags != 0 && r.cond_matches(&root, true))- .fold(0u128, |m, r| m | r.bit);- if inner != 0 && self.form_has_flag(&root, inner, depth - 1, excluded) {+ .filter(|r| r.cont.meets(flags) && r.cond_matches(&root, true))+ .fold(Mask::EMPTY, |m, r| m | r.bit);+ if !inner.is_empty() && self.form_has_flag(&root, inner, depth - 1, deny) { return true; } }@@ -651,21 +815,55 @@ impl AffixDict { false } - /// Is `w` exactly (case and all) a stem or an affixed form of one?+ /// Is `w` exactly (case and all) a stem or an affixed form of one? `ICONV` and `IGNORE`+ /// apply first. pub fn contains(&self, w: &str) -> bool {- self.contains_excluding(w, self.forbidden)+ self.contains_except(&self.normalize(w), Mask::EMPTY) } - /// Exactly spelled dictionary words eligible for suggestions (NOSUGGEST stems excluded).+ /// Exactly spelled dictionary words eligible for suggestions (NOSUGGEST and SUBSTANDARD+ /// stems excluded). pub fn contains_suggestible(&self, w: &str) -> bool {- self.contains_excluding(w, self.forbidden | self.no_suggest)+ self.contains_except(&self.normalize(w), self.no_suggest | self.substandard)+ }++ /// `w` after the `ICONV` conversions (at each position the longest match wins) and without+ /// the `IGNORE` characters.+ fn normalize<'a>(&self, w: &'a str) -> std::borrow::Cow<'a, str> {+ if self.iconv.is_empty() && self.ignore.is_empty() {+ return std::borrow::Cow::Borrowed(w);+ }+ // One pass: the input is copied only from the first change on.+ let mut out: Option<String> = None;+ let mut rest = w;+ while let Some(c) = rest.chars().next() {+ let done = w.get(..w.len() - rest.len()).unwrap_or_default();+ let hit = self.iconv.iter().filter(|(from, _)| !from.is_empty() && rest.starts_with(from.as_str())).max_by_key(|(from, _)| from.len());+ match hit {+ Some((from, to)) => {+ out.get_or_insert_with(|| done.to_string()).extend(to.chars().filter(|c| !self.ignore.contains(c)));+ rest = rest.get(from.len()..).unwrap_or_default();+ }+ None => {+ if self.ignore.contains(&c) {+ out.get_or_insert_with(|| done.to_string());+ } else if let Some(o) = out.as_mut() {+ o.push(c);+ }+ rest = rest.get(c.len_utf8()..).unwrap_or_default();+ }+ }+ }+ out.map_or(std::borrow::Cow::Borrowed(w), std::borrow::Cow::Owned) } - fn contains_excluding(&self, w: &str, excluded: u128) -> bool {+ /// [`AffixDict::contains`] on normalized input, where stems carrying a flag in `deny` don't+ /// count (case variants of `KEEPCASE` stems).+ fn contains_except(&self, w: &str, deny: Mask) -> bool { if w.is_empty() || w.len() > MAX_WORD_BYTES { return false; }- if self.stem_flags_excluding(w, excluded).is_some() || self.suffixed(w, 0, excluded) {+ if self.stem_flags_except(w, deny).is_some() || self.suffixed(w, Mask::EMPTY, deny) { return true; } let mut root = String::with_capacity(w.len() + 8);@@ -680,13 +878,13 @@ impl AffixDict { if !r.cond_matches(&root, false) { continue; }- if self.stem_flags_excluding(&root, excluded).is_some_and(|f| f & r.bit != 0) {+ if self.stem_flags_except(&root, deny).is_some_and(|f| f.meets(r.bit)) { return true; }- if self.form_has_flag(&root, r.bit, INNER_CHAIN, excluded) {+ if self.form_has_flag(&root, r.bit, INNER_CHAIN, deny) { return true; }- if r.cross && self.suffixed(&root, r.bit, excluded) {+ if r.cross && self.suffixed(&root, r.bit, deny) { return true; } }@@ -695,7 +893,7 @@ impl AffixDict { /// Is `w` a stem with one suffix? With `need` set (a prefix was taken off), the stem must /// carry those flags too and the suffix must allow cross products.- fn suffixed(&self, w: &str, need: u128, excluded: u128) -> bool {+ fn suffixed(&self, w: &str, need: Mask, deny: Mask) -> bool { let mut root = String::with_capacity(w.len() + 8); // Each split point from the end: the appended string is `w[b..]`, at most the longest // suffix, and something must stay before it.@@ -710,18 +908,18 @@ impl AffixDict { let rules = || g.rules.iter().filter_map(|&i| self.suffixes.get(i as usize)); // 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_excluding(&root, excluded)- && f & need == need- && rules().any(|r| f & r.bit != 0 && (need == 0 || r.cross) && r.cond_matches(&root, true))+ if let Some(f) = self.stem_flags_except(&root, deny)+ && f.covers(need)+ && rules().any(|r| f.meets(r.bit) && (need.is_empty() || r.cross) && r.cond_matches(&root, true)) { return true; } // Continuation classes: `root` need not be a stem carrying this rule's flag — // it may be a form that gained the flag from another rule's continuation. The // matching rules' flags are looked for together, in one walk.- if need == 0 {- let flags = rules().filter(|r| r.cond_matches(&root, true)).fold(0u128, |m, r| m | r.bit);- if flags != 0 && self.form_has_flag(&root, flags, INNER_CHAIN, excluded) {+ if need.is_empty() {+ let flags = rules().filter(|r| r.cond_matches(&root, true)).fold(Mask::EMPTY, |m, r| m | r.bit);+ if !flags.is_empty() && self.form_has_flag(&root, flags, INNER_CHAIN, deny) { return true; } }@@ -732,26 +930,29 @@ impl AffixDict { /// Is `w` correct, Hunspell style: as written, or — for a capitalized word or one in capitals /// — in lower case (and capitalized), since a sentence may start with any word and headings- /// may be in capitals. Lower-case input never matches a capitalized stem (`warszawa`).+ /// may be in capitals. Lower-case input never matches a capitalized stem (`warszawa`), and a+ /// `KEEPCASE` stem matches only as written. pub fn check(&self, w: &str) -> bool {- self.check_excluding(w, self.forbidden)+ self.check_except(w, Mask::EMPTY) } /// Whether a suggestion is correct in its displayed case and has an eligible stem. pub fn can_suggest(&self, w: &str) -> bool {- self.check_excluding(w, self.forbidden | self.no_suggest)+ self.check_except(w, self.no_suggest | self.substandard) } - fn check_excluding(&self, w: &str, excluded: u128) -> bool {- if self.contains_excluding(w, excluded) {+ fn check_except(&self, w: &str, deny: Mask) -> bool {+ let w = self.normalize(w);+ if self.contains_except(&w, deny) { return true; }- match casing(w) {+ let deny = deny | self.keepcase;+ match casing(&w) { Casing::Lower | Casing::Mixed => false,- Casing::Initial => self.contains_excluding(&w.to_lowercase(), excluded),+ Casing::Initial => self.contains_except(&w.to_lowercase(), deny), Casing::Upper => { let lower = w.to_lowercase();- self.contains_excluding(&capitalize(&lower), excluded) || self.contains_excluding(&lower, excluded)+ self.contains_except(&capitalize(&lower), deny) || self.contains_except(&lower, deny) } } }@@ -766,11 +967,11 @@ impl AffixDict { let mut cross_sfx = 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, u128)> = Vec::new();- let mut queued: std::collections::HashSet<(String, u128)> = std::collections::HashSet::new();+ let mut chain: Vec<(String, Mask)> = Vec::new();+ let mut queued: std::collections::HashSet<(String, Mask)> = std::collections::HashSet::new(); let mut work = 0usize; for r in &self.suffixes {- if f & r.bit == 0 || !r.cond_matches(stem, true) {+ if !f.meets(r.bit) || !r.cond_matches(stem, true) { continue; } let Some(base) = stem.strip_suffix(&*r.strip) else { continue };@@ -778,7 +979,7 @@ impl AffixDict { if r.cross { cross_sfx.push(form.clone()); }- if r.cont != 0 && queued.insert((form.clone(), r.cont)) {+ if !r.cont.is_empty() && queued.insert((form.clone(), r.cont)) { chain.push((form.clone(), r.cont)); } out.push(form);@@ -804,7 +1005,7 @@ impl AffixDict { } let Some(base) = form.strip_suffix(&*r.strip) else { continue }; let next = format!("{base}{}", r.add);- if r.cont != 0 && queued.insert((next.clone(), r.cont)) {+ if !r.cont.is_empty() && queued.insert((next.clone(), r.cont)) { chain.push((next.clone(), r.cont)); } out.push(next);@@ -815,7 +1016,7 @@ impl AffixDict { } } for r in &self.prefixes {- if f & r.bit == 0 || !r.cond_matches(stem, false) {+ if !f.meets(r.bit) || !r.cond_matches(stem, false) { continue; } let Some(rest) = stem.strip_prefix(&*r.strip) else { continue };@@ -839,11 +1040,11 @@ impl AffixDict { } /// The suffix rules carrying any flag of `mask`, in rule order.- fn suffixes_with(&self, mask: u128) -> Vec<u32> {+ fn suffixes_with(&self, mask: Mask) -> Vec<u32> { let mut rules: Vec<u32> = Vec::new(); let mut lists = 0; for (bit, list) in self.sfx_by_flag.iter().enumerate() {- if bit < 128 && mask & (1u128 << bit) != 0 && !list.is_empty() {+ if mask.meets(Mask::bit(bit)) && !list.is_empty() { rules.extend_from_slice(list); lists += 1; }@@ -904,13 +1105,21 @@ impl AffixDict { for (a, b) in &self.rep { out.push_str(&format!("REP {} {}\n", a.replace(' ', "_"), b.replace(' ', "_"))); }- for (directive, mask) in [("FORBIDDENWORD", self.forbidden), ("NOSUGGEST", self.no_suggest)] {- if mask != 0+ for (directive, mask) in+ [("FORBIDDENWORD", self.forbidden), ("NOSUGGEST", self.no_suggest), ("SUBSTANDARD", self.substandard), ("KEEPCASE", self.keepcase)]+ {+ if !mask.is_empty() && let Some(c) = self.flag_char(mask) { out.push_str(&format!("{directive} {c}\n")); } }+ for (a, b) in &self.iconv {+ out.push_str(&format!("ICONV {a} {b}\n"));+ }+ if !self.ignore.is_empty() {+ out.push_str(&format!("IGNORE {}\n", self.ignore.iter().collect::<String>()));+ } let z = |s: &str| if s.is_empty() { "0".to_string() } else { s.to_string() }; for (kind, rules) in [("PFX", &self.prefixes), ("SFX", &self.suffixes)] { for r in rules {@@ -934,8 +1143,8 @@ impl AffixDict { let p = prev.iter().zip(b).take_while(|(x, y)| x == y).count().min(255); raw.push(p as u8); raw.extend_from_slice(b.get(p..).unwrap_or_default());- let mask = self.flags.get(i).and_then(|&id| self.sets.get(usize::from(id))).copied().unwrap_or(0);- if mask != 0 {+ let mask = self.flags.get(i).and_then(|&id| self.sets.get(usize::from(id))).copied().unwrap_or_default();+ if !mask.is_empty() { raw.push(b'/'); raw.extend_from_slice(self.flag_chars_for(mask).as_bytes()); }@@ -962,26 +1171,24 @@ impl AffixDict { comments.push(c.to_string()); continue; }- let f: Vec<&str> = line.split_whitespace().collect();+ let f: Vec<&str> = split_fields(line).collect(); let at = |k: usize| f.get(k).copied().unwrap_or(""); match at(0) { "TRY" => rules.try_chars = cap_try(at(1)), "KEY" => rules.key = at(1).to_string(),- "MAP" => rules.map.push(at(1).chars().collect()),- "REP" => rules.rep.push((at(1).replace('_', " "), at(2).replace('_', " "))),- directive @ ("FORBIDDENWORD" | "NOSUGGEST") => {- let mut it = at(1).chars();- match (it.next(), it.next()) {- (Some(c), None) if !c.is_whitespace() => {- if directive == "FORBIDDENWORD" {- rules.forbidden = Some(c);- } else {- rules.no_suggest = Some(c);- }- }- _ => return Err(format!("affix dictionary: bad {directive} in `{line}`")),+ directive @ ("FORBIDDENWORD" | "NOSUGGEST" | "SUBSTANDARD" | "KEEPCASE") => {+ let c = one_flag(at(1), directive, 0).map_err(|_| format!("affix dictionary: bad {directive} in `{line}`"))?;+ match directive {+ "FORBIDDENWORD" => rules.forbidden = Some(c),+ "NOSUGGEST" => rules.no_suggest = Some(c),+ "SUBSTANDARD" => rules.substandard = Some(c),+ _ => rules.keepcase = Some(c), } }+ "ICONV" if f.len() == 3 => rules.iconv.push((at(1).to_string(), at(2).to_string())),+ "IGNORE" => rules.ignore = at(1).chars().collect(),+ "MAP" => rules.map.push(at(1).chars().collect()),+ "REP" => rules.rep.push((at(1).replace('_', " "), at(2).replace('_', " "))), kind @ ("PFX" | "SFX") => { let mut fc = at(1).chars(); let (Some(flag), None) = (fc.next(), fc.next()) else { return Err(format!("affix dictionary: bad flag in `{line}`")) };@@ -1223,7 +1430,7 @@ pub(crate) mod tests { #[test] fn more_than_64_distinct_flags_load() { // VERO pt_BR uses 106 distinct flags (FLAG UTF-8). Build one with every basic Latin- // letter, the digits and eight accented letters: 70 flags, 64 < n <= 128.+ // letter, the digits and eight accented letters: 70 flags, more than 64. let mut flags = String::new(); let mut aff = String::from("SET UTF-8\nFLAG UTF-8\n"); let chars: Vec<char> = ('a'..='z').chain('A'..='Z').chain('0'..='9').chain(['á', 'é', 'í', 'ó', 'ú', 'ç', 'ã', 'õ']).collect();@@ -1239,6 +1446,24 @@ pub(crate) mod tests { assert!(!d.contains("verboß"), "ß is not a flag and not a stem"); } + #[test]+ fn more_than_128_distinct_flags_load() {+ // The Hungarian dictionary uses 158 single-byte flags: 200 flags here, U+0080 up.+ let flags: String = (0x80u32..0x80 + 200).filter_map(char::from_u32).collect();+ let mut aff = String::from("SET UTF-8\n");+ for (i, c) in flags.chars().enumerate() {+ aff.push_str(&format!("SFX {c} N 1\nSFX {c} 0 x{i} .\n"));+ }+ let half: String = flags.chars().step_by(2).collect();+ let d = AffixDict::from_hunspell(&aff, &format!("1\nszó/{half}\n"), &[]).unwrap();+ assert!(d.contains("szóx0") && d.contains("szóx198") && d.contains("szóx130"));+ assert!(!d.contains("szóx1") && !d.contains("szóx199") && !d.contains("szóx131"));+ let back = AffixDict::from_bytes(&d.to_bytes()).unwrap();+ assert!(back.contains("szóx198") && !back.contains("szóx199"));+ let too_many: String = (0x100u32..0x100 + 257).filter_map(char::from_u32).collect();+ assert!(AffixDict::from_hunspell("SET UTF-8\n", &format!("1\nszó/{too_many}\n"), &[]).is_err());+ }+ #[test] fn forbidden_words_are_rejected() { // VERO pt_BR marks spellings it must reject (flag ý under FLAG UTF-8) as stems carrying@@ -1364,6 +1589,167 @@ pub(crate) mod tests { assert!(d.contains("kot") && d.contains("koty")); } + #[test]+ fn flag_aliases_stand_for_flag_sets() {+ // `AF`: `/1` in the `.dic` and in a continuation class means the first alias's flags.+ // Morphological aliases (`AM`) and the fields after a stem are skipped.+ let aff = "SET UTF-8\nAF 2\nAF ab # 1\nAF c # 2\nAM 1\nAM po:noun\nSFX a Y 1\nSFX a 0 ok/2 . 1\nSFX b Y 1\nSFX b 0 at . 1\nSFX c Y 1\nSFX c 0 ban . 1\n";+ let d = AffixDict::from_hunspell(aff, "1\nház/1\t1\n", &[]).unwrap();+ assert!(d.contains("ház") && d.contains("házok") && d.contains("házat") && d.contains("házokban"));+ assert!(!d.contains("házban"), "the stem has a and b, not c");+ // An undefined alias reads as no flags, as in Hunspell: the stem loads, without affixes.+ let undefined = AffixDict::from_hunspell(aff, "2\nház/3\nkert/1\n", &[]).unwrap();+ assert!(undefined.check("ház") && !undefined.check("házok"), "alias 3 is not defined");+ assert!(undefined.check("kertok"), "the other entries keep their aliases");+ let back = AffixDict::from_bytes(&d.to_bytes()).unwrap();+ assert!(back.contains("házokban") && !back.contains("házban"), "aliases are expanded in the compact format");+ }++ #[test]+ fn iconv_and_ignore_normalize_input() {+ // Ligatures read as their letters; `IGNORE` characters vanish from input and stems.+ let aff = "SET UTF-8\nICONV 2\nICONV fi fi\nICONV ffl ffl\nIGNORE ()\nSFX a Y 1\nSFX a 0 s .\n";+ let d = AffixDict::from_hunspell(aff, "2\nfine/a\nwaf(fle)/a\n", &[]).unwrap();+ assert!(d.check("fine") && d.check("fines") && d.check("Fine") && d.contains("fine"));+ assert!(d.check("waffle") && d.check("waffles") && d.check("wa(ffle)"));+ assert!(!d.check("finer"));+ let back = AffixDict::from_bytes(&d.to_bytes()).unwrap();+ assert!(back.check("fines") && back.check("waffle") && back.check("waf(fle)"));+ }++ #[test]+ fn alias_numbers_are_read_as_hunspell_reads_them() {+ // Checked with the Hunspell 1.7 command line. With an `AF` table a flag field is the alias+ // its leading digits number: `fal/2x` is alias 2, `lab/b` has no flags.+ let aff = "SET UTF-8\nAF 2\nAF B\nAF C\nSFX B Y 1\nSFX B 0 s .\nSFX C Y 1\nSFX C 0 x .\nSFX b Y 1\nSFX b 0 q .\n";+ let d = AffixDict::from_hunspell(aff, "3\nlab/b\nfal/2x\nkut/1\n", &[]).unwrap();+ assert!(d.check("lab") && !d.check("labq") && !d.check("labs"));+ assert!(d.check("falx") && !d.check("fals") && d.check("kuts"));+ // A table without its count line is rejected: no alias applies.+ let broken = AffixDict::from_hunspell("SET UTF-8\nAF B\nAF C\nSFX B Y 1\nSFX B 0 s .\n", "2\nlab/1\nfal/2\n", &[]).unwrap();+ assert!(broken.check("lab") && !broken.check("labs") && !broken.check("fals"));+ // `AF 0` is rejected the same way: `lab/B` keeps its literal flag.+ let zero = AffixDict::from_hunspell("SET UTF-8\nAF 0\nSFX B Y 1\nSFX B 0 s .\n", "1\nlab/B\n", &[]).unwrap();+ assert!(zero.check("lab") && zero.check("labs"));+ // An undefined alias in a continuation class leaves the rule in force, without it.+ let cont = AffixDict::from_hunspell("SET UTF-8\nAF 1\nAF a\nSFX a Y 1\nSFX a 0 ok/7 .\n", "1\nhaz/1\n", &[]).unwrap();+ assert!(cont.check("haz") && cont.check("hazok"));+ }++ #[test]+ fn a_rep_count_line_may_carry_a_comment() {+ let d = AffixDict::from_hunspell("SET UTF-8\nREP 1 # confusions\nREP x y\n", "1\nay\n", &[]).unwrap();+ assert_eq!(d.rep(), [("x".to_string(), "y".to_string())]);+ }++ #[test]+ fn count_lines_may_carry_a_comment() {+ // Hunspell reads the first `AF` and `ICONV` line as the count, comment and all: `AF 2 #`+ // is no alias, and `ICONV 1 # …` doesn't turn `1` into `#`.+ let aff = "SET UTF-8\nAF 2 # aliases\nAF a # 1\nAF b # 2\nICONV 1 # ligatures\nICONV q y\nSFX a Y 1\nSFX a 0 ok .\nSFX b Y 1\nSFX b 0 at .\n";+ let d = AffixDict::from_hunspell(aff, "3\nhaz/1\nkert/2\na1\n", &[]).unwrap();+ assert!(d.check("hazok") && !d.check("hazat") && d.check("kertat") && !d.check("kertok"), "alias 1 is `a`");+ assert!(d.check("a1") && !d.check("a#"));+ // The UTF-8 reader keeps such a line as it is, so both readers agree on it.+ let (a, dic) = crate::hunspell::decode(aff.as_bytes(), "1\nhaz/1\n".as_bytes()).unwrap();+ assert!(a.contains("AF 2 # aliases") && a.contains("ICONV 1 # ligatures"), "{a}");+ assert!(AffixDict::from_hunspell(&a, &dic, &[]).unwrap().check("hazok"));+ }++ #[test]+ fn dic_entries_may_hold_spaces() {+ // `ad hoc/B` is one entry, as in Hunspell; `ad` alone is no word.+ let aff = "SET UTF-8\nSFX B Y 1\nSFX B 0 s .\n";+ // Checked with the Hunspell 1.7 command line: without a tab or a morphological field the+ // whole line is the entry (`kot/B 12` has the flag `B`), and leading blanks belong to it+ // (` lab` is no `lab`).+ let d = AffixDict::from_hunspell(aff, "5\nad hoc/B\tpo:adv\nword/B po:noun\nkot/B 12\n lab/B\nés így/B\n", &[]).unwrap();+ assert!(d.contains("ad hoc") && d.contains("ad hocs") && d.contains("és így") && d.contains("és ígys"));+ assert!(!d.contains("ad") && !d.contains("hoc") && !d.contains("ads") && !d.contains("így"));+ assert!(d.contains("words") && d.contains("kots") && !d.contains("kot/B"));+ assert!(!d.contains("lab") && !d.contains("labs"));+ }++ #[test]+ fn ignored_characters_leave_the_stripped_text_alone() {+ // Hunspell removes `IGNORE` characters from the appended text, not from the stripped+ // one: `SFX a bx c` never matches the stem `abx` (stored as `ab`), so `ac` is wrong,+ // while `SFX a b cx` appends `c` (checked with the Hunspell 1.7 command line).+ let strip = AffixDict::from_hunspell("SET UTF-8\nIGNORE x\nSFX a Y 1\nSFX a bx c .\n", "1\nabx/a\n", &[]).unwrap();+ assert!(strip.check("ab") && strip.check("abx") && !strip.check("ac"));+ let add = AffixDict::from_hunspell("SET UTF-8\nIGNORE x\nSFX a Y 1\nSFX a b cx .\n", "1\nab/a\n", &[]).unwrap();+ assert!(add.check("ac") && add.check("acx"));+ for d in [&strip, &add] {+ let back = AffixDict::from_bytes(&d.to_bytes()).unwrap();+ assert_eq!(back.check("ac"), d.check("ac"));+ }+ }++ #[test]+ fn normalizing_borrows_when_nothing_changes() {+ let d = AffixDict::from_hunspell("SET UTF-8\nICONV 1\nICONV fi fi\nIGNORE ()\n", "1\nfine\n", &[]).unwrap();+ assert!(matches!(d.normalize("fine"), std::borrow::Cow::Borrowed("fine")));+ assert_eq!(d.normalize("(fi)ne"), "fine");+ assert!(matches!(dict().normalize("kot"), std::borrow::Cow::Borrowed("kot")));+ }++ #[test]+ fn keepcase_stems_match_only_as_written() {+ let aff = "SET UTF-8\nKEEPCASE k\nSFX a Y 1\nSFX a 0 s .\n";+ let d = AffixDict::from_hunspell(aff, "3\npH/k\nkm/ka\nkot/a\n", &[]).unwrap();+ assert!(d.check("pH") && d.check("km") && d.check("kms"));+ assert!(!d.check("PH") && !d.check("Km") && !d.check("KM") && !d.check("Kms"));+ assert!(d.check("Kot") && d.check("KOTS"), "other stems still follow the sentence and heading case");+ let back = AffixDict::from_bytes(&d.to_bytes()).unwrap();+ assert!(back.check("km") && !back.check("Km"));+ }++ #[test]+ fn substandard_and_lemma_present_words_are_correct() {+ // `SUBSTANDARD` and `LEMMA_PRESENT` steer morphological generation, and a SUBSTANDARD+ // stem is never suggested; the stems and rules they mark are still correct.+ let aff = "SET UTF-8\nSUBSTANDARD x\nLEMMA_PRESENT l\nSFX a Y 2\nSFX a 0 y .\nSFX a 0 em/x .\n";+ let d = AffixDict::from_hunspell(aff, "2\nkot/a\npies/axl\n", &[]).unwrap();+ for dict in [&d, &AffixDict::from_bytes(&d.to_bytes()).unwrap()] {+ assert!(dict.check("kot") && dict.check("koty") && dict.check("kotem"));+ assert!(dict.check("pies") && dict.check("piesy") && dict.check("Piesem"));+ assert!(dict.can_suggest("koty") && dict.can_suggest("kotem") && dict.contains_suggestible("kot"));+ assert!(!dict.can_suggest("piesy") && !dict.can_suggest("Pies") && !dict.contains_suggestible("pies"));+ }+ }++ #[test]+ fn unsupported_directives_are_refused_and_the_rest_load() {+ // A dictionary that needs a directive outside this engine fails to load rather than+ // accepting the wrong words.+ for dir in UNSUPPORTED {+ assert!(AffixDict::from_hunspell(&format!("SET UTF-8\n{dir} X\n"), DIC, &[]).is_err(), "{dir} should be refused");+ }+ // The directives this engine reads, with no other change to the dictionary (`AF` would+ // turn the flags of `DIC` into alias numbers; it has tests of its own).+ for line in ["AM 1\nAM po:noun", "ICONV 1\nICONV fi fi", "IGNORE ()", "KEEPCASE k", "SUBSTANDARD x", "LEMMA_PRESENT l"] {+ let d = AffixDict::from_hunspell(&format!("{AFF}{line}\n"), DIC, &[]).unwrap_or_else(|e| panic!("{line}: {e}"));+ assert!(d.check("koty") && d.check("niekoty") && !d.check("kotx"), "{line}");+ }+ }++ #[test]+ fn fields_split_on_spaces_and_tabs_only() {+ // A flag byte read as U+00A0 (no-break space) or U+0085 is a flag, not a separator.+ let aff = "SET UTF-8\nSFX \u{a0} Y 1\nSFX \u{a0} 0 y .\nSFX \u{85} Y 1\nSFX \u{85} 0 em .\n";+ let d = AffixDict::from_hunspell(aff, "1\nkot/\u{a0}\u{85}\n", &[]).unwrap();+ assert!(d.contains("koty") && d.contains("kotem"));+ let back = AffixDict::from_bytes(&d.to_bytes()).unwrap();+ assert!(back.contains("koty") && back.contains("kotem"));+ // The same bytes as one-flag directives, as Hunspell reads them.+ let aff = "SET UTF-8\nFORBIDDENWORD \u{a0}\nKEEPCASE \u{85}\nSFX a Y 1\nSFX a 0 s .\n";+ let d = AffixDict::from_hunspell(aff, "3\nkot/a\nrossz/\u{a0}\npH/\u{85}\n", &[]).unwrap();+ for dict in [&d, &AffixDict::from_bytes(&d.to_bytes()).unwrap()] {+ assert!(dict.check("kots") && !dict.check("rossz"));+ assert!(dict.check("pH") && !dict.check("PH"));+ }+ }+ #[test] fn unsupported_and_broken_input_is_an_error() { assert!(AffixDict::from_hunspell("FLAG long\n", DIC, &[]).is_err());@@ -218,13 +218,23 @@ fn decode_high(bytes: &[u8], high: &str) -> String { /// (UTF-8 when there is none; ISO 8859-2 is the other one Polish dictionaries use, ISO 8859-1 the /// one the original Portuguese VERO uses, ISO 8859-9 the one older Turkish dictionaries use, KOI8-R /// and Windows-1251 the ones older Russian dictionaries use).+///+/// Under a UTF-8 `SET` with single-byte flags (no `FLAG` line, or `FLAG DEFAULT`), flags are bytes,+/// as in Hunspell: the Hungarian dictionary writes its affix flags and flag aliases as bytes above+/// 0x7F that aren't UTF-8. Those fields are read byte by byte (each byte becomes U+0000..U+00FF,+/// one flag per byte); the words, affixes and conditions around them are UTF-8. With any other+/// `FLAG` (`UTF-8`, `num`, `long`) flags are text and the whole file is UTF-8: `SFX 1234` keeps+/// its number (the Turkish dictionary's `FLAG num`). pub fn decode(aff: &[u8], dic: &[u8]) -> Result<(String, String), String> {- let head = String::from_utf8_lossy(aff.get(..aff.len().min(8192)).unwrap_or_default()).to_string();- let set = head.lines().find_map(|l| l.trim_start_matches('\u{feff}').strip_prefix("SET ")).map(str::trim).unwrap_or("UTF-8").to_ascii_uppercase();+ let (set, flag) = directives(aff);+ let set = set.unwrap_or_else(|| "UTF-8".into()); match set.as_str() { "UTF-8" | "UTF8" => {- let text = |b: &[u8], what: &str| String::from_utf8(b.to_vec()).map_err(|e| format!("{what}: not UTF-8: {e}"));- Ok((text(aff, ".aff")?, text(dic, ".dic")?))+ if flag.as_deref().is_some_and(|f| f != "DEFAULT") {+ let text = |b: &[u8], what: &str| String::from_utf8(b.to_vec()).map_err(|e| format!("{what}: not UTF-8: {e}"));+ return Ok((text(aff, ".aff")?, text(dic, ".dic")?));+ }+ Ok((decode_utf8_aff(aff)?, decode_utf8_dic(dic)?)) } "ISO8859-1" | "ISO-8859-1" => Ok((decode_latin1(aff), decode_latin1(dic))), "ISO8859-2" | "ISO-8859-2" => Ok((decode_latin2(aff), decode_latin2(dic))),@@ -235,6 +245,225 @@ pub fn decode(aff: &[u8], dic: &[u8]) -> Result<(String, String), String> { } } +/// The first arguments of the `.aff`'s first `SET` and `FLAG` lines, in capitals, found in one pass+/// over the whole file: a `FLAG UTF-8` after the rules still decides how the `.dic` flags are read,+/// as in Hunspell. Only the first argument counts: `FLAG UTF-8 # Unicode flags` is UTF-8.+fn directives(aff: &[u8]) -> (Option<String>, Option<String>) {+ let (mut set, mut flag) = (None, None);+ for l in aff.split(|&b| b == b'\n') {+ if set.is_some() && flag.is_some() {+ break;+ }+ let l = l.strip_prefix("\u{feff}".as_bytes()).unwrap_or(l);+ let slot = match l.first() {+ Some(b'S') if set.is_none() => (&mut set, &b"SET"[..]),+ Some(b'F') if flag.is_none() => (&mut flag, &b"FLAG"[..]),+ _ => continue,+ };+ let arg = l+ .strip_prefix(slot.1)+ .filter(|rest| rest.first().is_some_and(|b| matches!(b, b' ' | b'\t')))+ .and_then(|rest| rest.split(|b| matches!(b, b' ' | b'\t' | b'\r')).find(|f| !f.is_empty()));+ if let Some(a) = arg {+ *slot.0 = Some(String::from_utf8_lossy(a).to_ascii_uppercase());+ }+ }+ (set, flag)+}++/// Directives whose one argument is a flag.+const FLAG_DIRECTIVES: &[&str] = &[+ "CIRCUMFIX",+ "COMPOUNDBEGIN",+ "COMPOUNDEND",+ "COMPOUNDFLAG",+ "COMPOUNDFORBIDFLAG",+ "COMPOUNDLAST",+ "COMPOUNDFIRST",+ "COMPOUNDMIDDLE",+ "COMPOUNDPERMITFLAG",+ "COMPOUNDROOT",+ "FORBIDDENWORD",+ "FORCEUCASE",+ "GENERATE",+ "HU_KOTOHANGZO",+ "KEEPCASE",+ "LEMMA_PRESENT",+ "NEEDAFFIX",+ "NOSUGGEST",+ "ONLYINCOMPOUND",+ "ONLYROOT",+ "PSEUDOROOT",+ "SUBSTANDARD",+ "WARN",+];++/// Directives that only describe the dictionary; a non-UTF-8 byte in them (the Hungarian+/// dictionary's `NAME` is ISO 8859-2) is replaced instead of failing the load.+const METADATA_DIRECTIVES: &[&str] = &["NAME", "HOME", "VERSION", "LANG", "AM"];++/// `text/flags`: the text as UTF-8 and the flags byte by byte (`\/` is a literal slash). `what`+/// names the line in an error.+fn text_and_flags(field: &[u8], what: impl Fn() -> String) -> Result<String, String> {+ let mut slash = None;+ let mut i = 0;+ while let Some(&b) = field.get(i) {+ if b == b'\\' && field.get(i + 1) == Some(&b'/') {+ i += 2;+ continue;+ }+ if b == b'/' {+ slash = Some(i);+ break;+ }+ i += 1;+ }+ let (text, flags) = match slash {+ Some(k) => (field.get(..k).unwrap_or_default(), field.get(k..).unwrap_or_default()),+ None => (field, &[][..]),+ };+ let mut out = std::str::from_utf8(text).map_err(|e| format!("{}: not UTF-8: {e}", what()))?.to_string();+ out.push_str(&decode_latin1(flags));+ Ok(out)+}++/// Split a line into fields on spaces and tabs, keeping the separators (so a line rebuilt from+/// its parts is the original).+fn fields(line: &[u8]) -> Vec<&[u8]> {+ let mut out = Vec::new();+ let mut start = 0;+ let mut in_sep = line.first().is_some_and(|b| matches!(b, b' ' | b'\t'));+ for (i, b) in line.iter().enumerate() {+ let sep = matches!(b, b' ' | b'\t');+ if sep != in_sep {+ out.push(line.get(start..i).unwrap_or_default());+ start = i;+ in_sep = sep;+ }+ }+ out.push(line.get(start..).unwrap_or_default());+ out+}++/// A UTF-8 `.aff` with single-byte flags (see [`decode`]).+fn decode_utf8_aff(aff: &[u8]) -> Result<String, String> {+ let mut out = String::with_capacity(aff.len());+ for (n, line) in aff.split(|&b| b == b'\n').enumerate() {+ if n > 0 {+ out.push('\n');+ }+ if let Ok(s) = std::str::from_utf8(line)+ && s.is_ascii()+ {+ out.push_str(s);+ continue;+ }+ let parts = fields(line);+ // Field index among the non-separator parts.+ let words: Vec<usize> =+ parts.iter().enumerate().filter(|(_, p)| !p.first().is_some_and(|b| matches!(b, b' ' | b'\t'))).map(|(i, _)| i).collect();+ let dir = words+ .first()+ .and_then(|&i| parts.get(i))+ .map(|p| String::from_utf8_lossy(p).trim_start_matches('\u{feff}').to_string())+ .unwrap_or_default();+ let what = || format!(".aff line {}", n + 1);+ if dir.starts_with('#') || METADATA_DIRECTIVES.contains(&dir.as_str()) {+ out.push_str(&String::from_utf8_lossy(line));+ continue;+ }+ // A trailing `# comment` (the Hungarian `AF` lines number themselves) is never a word or+ // a flag; from there on the line is kept as text. It can only start after the directive's+ // own fields, since a field such as an affix condition may itself begin with `#`.+ let field_text = |k: usize| words.get(k).and_then(|&w| parts.get(w)).map(|p| String::from_utf8_lossy(p).into_owned());+ let first_free = comment_field(&dir, field_text);+ let comment_at = words.iter().skip(first_free).position(|&w| parts.get(w).is_some_and(|f| f.first() == Some(&b'#'))).map(|k| k + first_free);+ // The field index of the next non-separator part.+ let mut next_field = 0;+ for (i, p) in parts.iter().enumerate() {+ let field = (!p.first().is_some_and(|b| matches!(b, b' ' | b'\t'))).then(|| {+ next_field += 1;+ next_field - 1+ });+ let in_comment = comment_at.is_some_and(|c| words.get(c).is_some_and(|&w| i >= w));+ let decoded = match (dir.as_str(), field) {+ _ if in_comment => String::from_utf8_lossy(p).into_owned(),+ ("AF" | "COMPOUNDRULE" | "SYLLABLENUM", Some(1)) => decode_latin1(p),+ ("PFX" | "SFX", Some(3)) | ("CHECKCOMPOUNDPATTERN", Some(1 | 2)) => text_and_flags(p, what)?,+ // A field that names one flag keeps its first byte, as Hunspell's `decode_flag`+ // does: `SFX é` is the flag 0xC3, the first of the two flags `kert/é` carries.+ ("PFX" | "SFX", Some(1)) => decode_latin1(p.get(..1).unwrap_or_default()),+ (d, Some(1)) if FLAG_DIRECTIVES.contains(&d) => decode_latin1(p.get(..1).unwrap_or_default()),+ _ => std::str::from_utf8(p).map_err(|e| format!("{}: not UTF-8: {e}", what()))?.to_string(),+ };+ out.push_str(&decoded);+ }+ }+ Ok(out)+}++/// The first field of an `.aff` line where a trailing `# comment` may start: after the+/// directive's own fields. A table's count line (`REP 12`, `PFX A Y 3`) has fewer fields than its+/// rows (`REP a b`, `PFX A 0 x .`). `field(k)` is the line's `k`-th field.+fn comment_field(dir: &str, field: impl Fn(usize) -> Option<String>) -> usize {+ let number = |k: usize| field(k).is_some_and(|f| f.parse::<usize>().is_ok());+ match dir {+ "PFX" | "SFX" if field(2).is_some_and(|f| f == "Y" || f == "N") && number(3) => 4,+ "PFX" | "SFX" => 5,+ "REP" | "ICONV" | "OCONV" | "PHONE" | "MAP" | "BREAK" | "AF" | "AM" | "COMPOUNDRULE" | "CHECKCOMPOUNDPATTERN"+ if number(1) && field(2).is_none_or(|f| f.starts_with('#')) =>+ {+ 2+ }+ "REP" | "ICONV" | "OCONV" | "PHONE" => 3,+ "CHECKCOMPOUNDPATTERN" => 4,+ _ => 2,+ }+}++/// Where the `word/flags` entry of a `.dic` line ends, as Hunspell reads it: at a tab or at the+/// first morphological field (a space or tab, two characters and `:`, as in ` po:noun`), whichever+/// comes first, so an entry may hold spaces (`ad hoc`); with neither, at the end of the line+/// (`kot/B 12` has the flags `B 12`, as in Hunspell).+pub(crate) fn dic_entry_end(line: &[u8]) -> usize {+ let blank = |b: &u8| matches!(b, b' ' | b'\t');+ let morph = line.windows(4).position(|w| matches!(w, [a, b, c, b':'] if blank(a) && !blank(b) && !blank(c)));+ let tab = line.iter().position(|&b| b == b'\t');+ let end = match (morph, tab) {+ (Some(m), Some(t)) => m.min(t),+ (m, t) => match m.or(t) {+ Some(e) => e,+ None => line.len(),+ },+ };+ // Blanks before the morphological field or the line end aren't part of the entry.+ line.get(..end).map_or(end, |e| e.iter().rposition(|b| !blank(b)).map_or(0, |k| k + 1))+}++/// A UTF-8 `.dic` with single-byte flags: the word is UTF-8, the flags after `/` are bytes.+fn decode_utf8_dic(dic: &[u8]) -> Result<String, String> {+ let mut out = String::with_capacity(dic.len());+ for (n, line) in dic.split(|&b| b == b'\n').enumerate() {+ if n > 0 {+ out.push('\n');+ }+ if let Ok(s) = std::str::from_utf8(line)+ && s.split_once('/').is_none_or(|(_, flags)| flags.is_ascii())+ {+ out.push_str(s);+ continue;+ }+ let what = || format!(".dic line {}", n + 1);+ // The first field is `word/flags` (it may hold spaces, see [`dic_entry_end`]);+ // morphological fields after it are UTF-8 text.+ let end = dic_entry_end(line);+ out.push_str(&text_and_flags(line.get(..end).unwrap_or_default(), what)?);+ let rest = line.get(end..).unwrap_or_default();+ out.push_str(std::str::from_utf8(rest).map_err(|e| format!("{}: not UTF-8: {e}", what()))?);+ }+ Ok(out)+}+ #[cfg(test)] mod tests { use super::*;@@ -280,6 +509,78 @@ mod tests { assert!(decode(b"SET ISO-8859-1\n", b"\xe7").is_ok()); } + #[test]+ fn utf8_dictionaries_keep_single_byte_flags() {+ // The Hungarian dictionary is `SET UTF-8` but writes flags as single bytes above 0x7F,+ // which aren't UTF-8; each byte is one flag. Its `NAME` line is ISO 8859-2.+ let aff = b"SET UTF-8\nNAME Magyar helyes\xedr\xe1si\nAF 2\nAF \xd5a # 1\nAF \xe9 # 2\nSFX \xd5 Y 1\nSFX \xd5 0 ok/2 . 7\nSFX \xe9 Y 1\nSFX \xe9 0 ban .\nSFX a Y 1\nSFX a 0 at .\n";+ let dic = "2\nház/1\t1\nkert/\u{e9}\n".as_bytes();+ let (a, d) = decode(aff, dic).unwrap();+ assert!(a.contains("AF \u{d5}a # 1") && a.contains("AF \u{e9} # 2") && a.contains("SFX \u{d5} 0 ok/2 . 7"));+ assert!(d.contains("kert/\u{c3}\u{a9}"), "a UTF-8 é in the flags is two one-byte flags");+ let dict = crate::affix::AffixDict::from_hunspell(&a, &d, &[]).unwrap();+ assert!(dict.check("ház") && dict.check("házok") && dict.check("házokban") && dict.check("házat"));+ assert!(!dict.check("házban"));+ // `FLAG UTF-8` keeps the whole file UTF-8, and words must still be UTF-8.+ let (_, d) = decode(b"SET UTF-8\nFLAG UTF-8\n", "kert/\u{e9}\n".as_bytes()).unwrap();+ assert_eq!(d, "kert/\u{e9}\n");+ assert!(decode(b"SET UTF-8\nSFX a Y 1\nSFX a 0 \xe9 .\n", b"").is_err(), "an affix must be UTF-8");+ assert!(decode(b"SET UTF-8\n", b"1\nh\xe1z/a\n").is_err(), "a stem must be UTF-8");+ }++ #[test]+ fn single_flag_fields_keep_their_first_byte() {+ // Without `FLAG UTF-8`, a UTF-8 `é` (0xC3 0xA9) in a field that names one flag is the+ // flag 0xC3; in a `.dic` flag list it is the two flags 0xC3 and 0xA9.+ let aff = "SET UTF-8\nKEEPCASE é\nSFX é Y 1\nSFX é 0 ok .\nSYLLABLENUM é\n".as_bytes();+ let (a, d) = decode(aff, "2\nkert/é\nNATO/é\n".as_bytes()).unwrap();+ assert!(a.contains("KEEPCASE \u{c3}\n") && a.contains("SFX \u{c3} Y 1") && a.contains("SFX \u{c3} 0 ok ."), "{a}");+ assert!(a.contains("SYLLABLENUM \u{c3}\u{a9}"), "SYLLABLENUM lists flags: {a}");+ assert!(d.contains("kert/\u{c3}\u{a9}"));+ let dict = crate::affix::AffixDict::from_hunspell(&a, &d, &[]).unwrap();+ assert!(dict.check("kert") && dict.check("kertok"));+ assert!(dict.check("NATO") && !dict.check("Nato"), "KEEPCASE é reaches NATO/é");+ }++ #[test]+ fn numeric_and_long_flags_stay_text() {+ // `FLAG num` (the Turkish dictionary) and `FLAG long`: no byte-wise flag reading, so a+ // number with a non-ASCII rule or comment keeps every digit.+ let aff = "SET UTF-8\nFLAG num\nFORBIDDENWORD 6000 # tiltott ő\nSFX 1234 Y 1\nSFX 1234 0 ları .\n";+ let (a, d) = decode(aff.as_bytes(), "1\nkitap/1234\n".as_bytes()).unwrap();+ assert_eq!(a, aff);+ assert_eq!(d, "1\nkitap/1234\n");+ let tr = crate::tr::dict::TrDict::from_hunspell(&a, &d).unwrap();+ assert!(tr.contains("kitapları") && tr.contains("kitap"));+ let long = "SET UTF-8\nFLAG long\nSFX Aa Y 1\nSFX Aa 0 ős .\n";+ assert_eq!(decode(long.as_bytes(), "1\nkő/Aa\n".as_bytes()).unwrap().0, long);+ assert!(decode(b"SET UTF-8\nFLAG num\n", b"1\nh\xe1z/1\n").is_err(), "the .dic must be UTF-8");+ // `FLAG DEFAULT` is the single-byte default.+ let (_, d) = decode(b"SET UTF-8\nFLAG default\n", b"1\nkert/\xe9\n").unwrap();+ assert_eq!(d, "1\nkert/\u{e9}\n");+ }++ #[test]+ fn flag_utf8_counts_anywhere_in_the_aff() {+ // A `FLAG UTF-8` after a long rule table still makes the `.dic` flags Unicode.+ let mut aff = b"SET UTF-8\nSFX a Y 400\n".to_vec();+ for _ in 0..400 {+ aff.extend_from_slice(b"SFX a 0 aaaaaaaaaaaaaaaaaaaaaaaa .\n");+ }+ aff.extend_from_slice(b"FLAG UTF-8\r\n");+ assert!(aff.len() > 8192);+ let (_, d) = decode(&aff, "kert/\u{e9}\n".as_bytes()).unwrap();+ assert_eq!(d, "kert/\u{e9}\n");+ }++ #[test]+ fn a_comment_after_flag_utf8_keeps_unicode_flags() {+ let (_, d) = decode(b"SET UTF-8\nFLAG UTF-8 # Unicode flags\n", "kert/\u{e9}\n".as_bytes()).unwrap();+ assert_eq!(d, "kert/\u{e9}\n");+ let (_, d) = decode(b"SET\tUTF-8 # comment\nFLAG\t\tUTF-8\n", "kert/\u{e9}\n".as_bytes()).unwrap();+ assert_eq!(d, "kert/\u{e9}\n", "repeated spaces and tabs separate the argument");+ }+ #[test] fn decodes_russian_single_byte_encodings() { // `ёлка` in KOI8-R (0xA3 0xCC 0xCB 0xC1) and in Windows-1251 (0xB8 0xEB 0xEA 0xE0).@@ -291,6 +592,41 @@ mod tests { assert_eq!(decode_high(&[0x98], CP1251_HIGH), "\u{fffd}"); } + #[test]+ fn comments_start_after_the_directive_fields() {+ let f = |line: &'static str| move |k: usize| line.split_whitespace().nth(k).map(str::to_string);+ assert_eq!(comment_field("PFX", f("PFX A Y 3 # count")), 4);+ assert_eq!(comment_field("SFX", f("SFX A 0 # .")), 5, "a rule's condition may be `#`");+ assert_eq!(comment_field("AF", f("AF 1234 # aliases")), 2);+ assert_eq!(comment_field("AF", f("AF Aa # 1")), 2);+ assert_eq!(comment_field("REP", f("REP 12")), 2);+ assert_eq!(comment_field("REP", f("REP # x")), 3);+ assert_eq!(comment_field("CHECKCOMPOUNDPATTERN", f("CHECKCOMPOUNDPATTERN a b c")), 4);+ assert_eq!(comment_field("KEEPCASE", f("KEEPCASE k")), 2);+ // A byte flag after an append text that begins with `#` stays a flag, not a comment.+ let (a, _) = decode(b"SET UTF-8\nSFX \xd5 Y 1 # header\nSFX \xd5 0 #/\xe9 . # rule\n", b"").unwrap();+ assert!(a.contains("SFX \u{d5} 0 #/\u{e9} . # rule"), "{a}");+ }++ #[test]+ fn dic_entries_end_where_hunspell_ends_them() {+ let end = |l: &str| l.get(..dic_entry_end(l.as_bytes())).unwrap().to_string();+ assert_eq!(end("word/A po:noun\tts:x"), "word/A", "the morphological field comes before the tab");+ assert_eq!(end("ad hoc/B\tpo:adv"), "ad hoc/B", "a tab ends a phrase");+ assert_eq!(end("ad hoc/B po:adv"), "ad hoc/B");+ assert_eq!(end("word/A 123"), "word/A 123", "neither: the whole line, as in Hunspell");+ assert_eq!(end("ad hoc/B "), "ad hoc/B");+ assert_eq!(end("word"), "word");+ assert_eq!(end("a:b/A"), "a:b/A", "a colon inside the word isn't a field");+ // Single-byte flags of a phrase entry are read byte by byte, then the UTF-8 morphology.+ let (_, d) = decode(b"SET UTF-8\n", b"1\nad hoc/\xe9\tpo:adv\n").unwrap();+ assert_eq!(d, "1\nad hoc/\u{e9}\tpo:adv\n");+ // Without a tab or a morphological field the whole line is the entry: its byte flags+ // don't fail the load (the Hungarian dictionary has such lines: `tök jó/396`).+ let (_, d) = decode(b"SET UTF-8\n", b"1\nad hoc/\xe9\n").unwrap();+ assert_eq!(d, "1\nad hoc/\u{e9}\n");+ }+ #[test] fn libreoffice_extension_dirs_follow_the_language() { // LibreOffice's IDs are per dictionary: Polish is `dict-pl`, Brazilian Portuguese