Skip to main content

lang_check/
morphology.rs

1//! Morphological acceptance: recognising a word built on material already known.
2//!
3//! Adding `algebra` to a dictionary implies `subalgebra`, `quasi-algebra` and
4//! `algebraicity` too. Materialising that closure is impossible — prefixes are freely
5//! composable, so the set is unbounded — so it is *recognised* at check time instead:
6//! peel affixes off the flagged token and ask whether what is left is known.
7//!
8//! # Why derivation is analysed but inflection is generated
9//!
10//! Stripping is the wrong tool for inflection. `occur` + `-ed` is `occurred`, not
11//! `occured`, and a stripper cannot see that: it removes `ed`, finds `occur`, and
12//! accepts a real misspelling. The same shape accepts `childs` and `mouses`, which are
13//! errors a checker exists to catch. Inflection is therefore *generated* — see
14//! [`crate::dictionary`] — where the orthographic conditions still apply.
15//!
16//! Derivation is the opposite case. `-ness`, `-ity`, `-able` and the prefixes attach
17//! without conditions, and no table can say which of twenty derivational suffixes a
18//! given root licenses, so generation would either miss most real words or invent
19//! nonsense. Here the residue constraint does the work: `subadditivity` is accepted
20//! only because `sub-` peels, `-ivity` restores `-ive`, and `additive` is a real word.
21//!
22//! # What stops this from accepting typos
23//!
24//! The guards here — a minimum root length, one prefix at most, a bounded number of
25//! suffix steps — are weak on their own. `untill` decomposes as `un` + `till`, and
26//! `till` really is a noun and a verb.
27//!
28//! The guard that carries the decision lives in [`crate::suppression`]: a token one
29//! edit away from a word the engine itself proposed is a typo, not a coinage. Measured
30//! over single-edit misspellings of common English words, decomposition alone accepts
31//! about 1%; with the suggestion test it accepts none. Nothing here should be read as
32//! safe without that check.
33
34use std::sync::{Arc, LazyLock};
35
36use harper_core::spell::{Dictionary as HarperDictionary, FstDictionary};
37
38use crate::dictionary::Dictionary;
39
40/// Hyphen characters treated as compound joiners.
41///
42/// The ASCII hyphen plus the two Unicode hyphens that survive a copy-paste from typeset
43/// text. En and em dashes are punctuation, not joiners, and are excluded.
44pub const HYPHENS: [char; 3] = ['-', '\u{2010}', '\u{2011}'];
45
46/// Shortest residue that may be treated as a root.
47///
48/// Three, not four, because `set`, `map` and `ring` are the stems this vocabulary is
49/// built from, and `subset`, `submap` and `coset` are words worth accepting. Measured
50/// against a typo corpus, three and four leak identically, so the shorter bound is free.
51const MIN_ROOT_CHARS: usize = 3;
52
53/// Shortest root when a derivational suffix was removed to reach it.
54///
55/// One more than [`MIN_ROOT_CHARS`], because a two-letter suffix landing on a
56/// three-letter word is a coincidence rather than a derivation: `noticable` peels to
57/// `notic`, then `-ic` peels to `not`, and the misspelling is silently accepted. Only
58/// prefixation reaches genuinely short roots — `subset`, `coset`, `submap`.
59const MIN_DERIVED_ROOT_CHARS: usize = 4;
60
61/// How many derivational suffixes may be peeled from one token.
62///
63/// Two reaches `subadditivity` (`sub-` then `-ivity`) and `equisatisfiability`
64/// (`-ability` then `-y`); a third step buys no attested word and widens the search.
65const MAX_SUFFIX_STEPS: usize = 2;
66
67/// The productive prefix list, embedded at build time.
68static PREFIX_LIST: &str = include_str!("../dictionaries/affixes/prefixes.txt");
69
70/// Prefixes ordered longest first, so `counter` is tried before `co`.
71static PREFIXES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
72    let mut prefixes: Vec<&'static str> = PREFIX_LIST
73        .lines()
74        .map(str::trim)
75        .filter(|line| !line.is_empty() && !line.starts_with('#'))
76        .collect();
77    prefixes.sort_unstable_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
78    prefixes
79});
80
81/// Harper's curated dictionary, consulted as a root oracle and for part of speech.
82///
83/// Already built unconditionally by the harper engine, so this is usually free; when
84/// harper is switched off it costs one lazy FST deserialisation.
85static CURATED: LazyLock<Arc<FstDictionary>> = LazyLock::new(FstDictionary::curated);
86
87/// A derivational suffix, with the stem endings it may put back.
88///
89/// `-ivity` restores `-ive` so `additivity` reaches `additive`; `-ity` restores a bare
90/// `e` so `activity` reaches `active`. An empty restoration is plain concatenation.
91struct SuffixRule {
92    suffix: &'static str,
93    restores: &'static [&'static str],
94}
95
96/// The derivational suffixes, longest first so `-ability` is tried before `-ity`.
97///
98/// Only *derivational* suffixes belong here — ones that build a new lexeme. Plural,
99/// past and progressive are inflectional and are generated instead, because stripping
100/// them accepts `childs` and `occured`.
101///
102/// Two deliberate omissions, both learned from measurement:
103/// * `-ly` does **not** restore a bare `e`. It would reach `immediate` from the
104///   misspelling `immediatly`, and no real word needs it — `simply` is served by the
105///   `le` restoration instead.
106/// * there is no `-ing`/`-ed`/`-s` rule, for the reason in the module docs.
107const SUFFIX_RULES: &[SuffixRule] = &[
108    SuffixRule {
109        suffix: "ization",
110        restores: &["ize", "izes", ""],
111    },
112    SuffixRule {
113        suffix: "isation",
114        restores: &["ise", "ises", ""],
115    },
116    SuffixRule {
117        suffix: "ability",
118        restores: &["able", ""],
119    },
120    SuffixRule {
121        suffix: "ibility",
122        restores: &["ible"],
123    },
124    SuffixRule {
125        suffix: "izable",
126        restores: &["ize", ""],
127    },
128    SuffixRule {
129        suffix: "ivity",
130        restores: &["ive"],
131    },
132    SuffixRule {
133        suffix: "ical",
134        restores: &["y", "ic", ""],
135    },
136    SuffixRule {
137        suffix: "ally",
138        restores: &["", "al", "ic"],
139    },
140    SuffixRule {
141        suffix: "ness",
142        restores: &["", "e"],
143    },
144    SuffixRule {
145        suffix: "less",
146        restores: &[""],
147    },
148    SuffixRule {
149        suffix: "ship",
150        restores: &[""],
151    },
152    SuffixRule {
153        suffix: "hood",
154        restores: &[""],
155    },
156    SuffixRule {
157        suffix: "wise",
158        restores: &[""],
159    },
160    SuffixRule {
161        suffix: "able",
162        restores: &["", "e"],
163    },
164    SuffixRule {
165        suffix: "ible",
166        restores: &[""],
167    },
168    SuffixRule {
169        suffix: "ity",
170        restores: &["", "e"],
171    },
172    SuffixRule {
173        suffix: "ism",
174        restores: &["", "e"],
175    },
176    SuffixRule {
177        suffix: "ist",
178        restores: &["", "e"],
179    },
180    SuffixRule {
181        suffix: "ful",
182        restores: &[""],
183    },
184    SuffixRule {
185        suffix: "oid",
186        restores: &["", "e"],
187    },
188    SuffixRule {
189        suffix: "ify",
190        restores: &["", "y"],
191    },
192    SuffixRule {
193        suffix: "ize",
194        restores: &["", "e"],
195    },
196    SuffixRule {
197        suffix: "ise",
198        restores: &["", "e"],
199    },
200    // No `e` restoration for `-ic`: it reaches `note` from `notic`, which is how the
201    // misspelling `noticable` slips through as `notic`+`able` and then `not`+`ic`. The
202    // `y` restoration is the one that earns its place -- `historic` from `history`.
203    SuffixRule {
204        suffix: "ic",
205        restores: &["", "y"],
206    },
207    SuffixRule {
208        suffix: "al",
209        restores: &["", "e"],
210    },
211    SuffixRule {
212        suffix: "ly",
213        restores: &["", "le"],
214    },
215    SuffixRule {
216        suffix: "or",
217        restores: &["", "e"],
218    },
219    SuffixRule {
220        suffix: "er",
221        restores: &["", "e"],
222    },
223];
224
225/// One affix removed on the way to a known root.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub enum AffixStep {
228    /// A productive prefix, as written in the prefix list.
229    Prefix(&'static str),
230    /// A derivational suffix, as written in [`SUFFIX_RULES`].
231    Suffix(&'static str),
232}
233
234/// A successful reading of a token as affixed known material.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct Analysis {
237    /// The known word the token was built on.
238    pub root: String,
239    /// The affixes removed to reach it, outermost first.
240    pub steps: Vec<AffixStep>,
241}
242
243impl Analysis {
244    /// The decomposition as `sub-+algebra`, for logs and the inspector.
245    #[must_use]
246    pub fn describe(&self) -> String {
247        let mut out = String::new();
248        for step in &self.steps {
249            match step {
250                AffixStep::Prefix(prefix) => {
251                    out.push_str(prefix);
252                    out.push_str("-+");
253                }
254                AffixStep::Suffix(suffix) => {
255                    out.push_str("+-");
256                    out.push_str(suffix);
257                    out.push('/');
258                }
259            }
260        }
261        out.push_str(&self.root);
262        out
263    }
264}
265
266/// Decides whether a flagged token is a well-formed derivation of known material.
267///
268/// Holds no dictionary: the servers keep theirs behind an `Arc<Mutex<..>>` and lock it
269/// per check, so the lexicon is passed to [`Self::analyze`] instead of borrowed here.
270#[derive(Debug, Clone)]
271pub struct AffixAnalyzer {
272    english: bool,
273}
274
275impl AffixAnalyzer {
276    /// Build an analyzer for a BCP-47 language tag.
277    ///
278    /// Only English is analysed. German `un-`/`über-` prefixation composes with
279    /// noun-noun compounding, which is a segmentation problem this cannot express, and
280    /// guessing there would suppress real misspellings.
281    #[must_use]
282    pub fn new(language: &str) -> Self {
283        Self {
284            english: language.to_lowercase().starts_with("en"),
285        }
286    }
287
288    /// Read `token` as affixed known material, or return `None`.
289    ///
290    /// At least one affix must be removed: a token that is already a word in its own
291    /// right yields `None`, not an empty analysis.
292    ///
293    /// The caller **must** still check the token against the engine's own suggestions
294    /// before acting on a hit; see the module docs.
295    #[must_use]
296    pub fn analyze(&self, token: &str, dictionary: Option<&Dictionary>) -> Option<Analysis> {
297        if !self.english {
298            return None;
299        }
300        let lowered = token.to_lowercase();
301        if !lowered
302            .chars()
303            .all(|c| c.is_ascii_alphabetic() || HYPHENS.contains(&c))
304        {
305            return None;
306        }
307
308        let mut steps = Vec::new();
309        strip_prefix(&lowered, dictionary, &mut steps).or_else(|| {
310            steps.clear();
311            strip_suffixes(&lowered, dictionary, &mut steps)
312        })
313    }
314}
315
316/// Try one productive prefix, then let the suffix rules finish the job.
317///
318/// At most one prefix: allowing two lets `recomend` read as `re` + `co` + `mend`, and
319/// no attested word needs a second.
320fn strip_prefix(
321    word: &str,
322    dictionary: Option<&Dictionary>,
323    steps: &mut Vec<AffixStep>,
324) -> Option<Analysis> {
325    for prefix in PREFIXES.iter() {
326        let Some(rest) = word.strip_prefix(prefix) else {
327            continue;
328        };
329        let residue = rest.strip_prefix(HYPHENS).unwrap_or(rest);
330        if residue.len() < MIN_ROOT_CHARS || residue.starts_with(HYPHENS) {
331            continue;
332        }
333        steps.push(AffixStep::Prefix(prefix));
334        if let Some(analysis) = strip_suffixes(residue, dictionary, steps) {
335            return Some(analysis);
336        }
337        steps.pop();
338    }
339    None
340}
341
342/// Peel derivational suffixes until the residue is a known word.
343///
344/// Recurses at most [`MAX_SUFFIX_STEPS`] deep and never reuses a rule, so it terminates
345/// and cannot cycle between two spellings of the same ending.
346fn strip_suffixes(
347    word: &str,
348    dictionary: Option<&Dictionary>,
349    steps: &mut Vec<AffixStep>,
350) -> Option<Analysis> {
351    // Only a residue counts as a root. At the top of a suffix-only search `steps` is
352    // empty and `word` is the token itself, and a token that is already a word is not
353    // an affixed form — it is one engine flagging what another engine's dictionary
354    // contains, which is a different feature.
355    let stripped_a_suffix = steps.iter().any(|s| matches!(s, AffixStep::Suffix(_)));
356    let floor = if stripped_a_suffix {
357        MIN_DERIVED_ROOT_CHARS
358    } else {
359        MIN_ROOT_CHARS
360    };
361    if !steps.is_empty() && word.chars().count() >= floor && is_known(word, dictionary) {
362        return Some(Analysis {
363            root: word.to_string(),
364            steps: steps.clone(),
365        });
366    }
367    if steps
368        .iter()
369        .filter(|s| matches!(s, AffixStep::Suffix(_)))
370        .count()
371        >= MAX_SUFFIX_STEPS
372    {
373        return None;
374    }
375
376    for rule in SUFFIX_RULES {
377        if steps.contains(&AffixStep::Suffix(rule.suffix)) {
378            continue;
379        }
380        let Some(stem) = word.strip_suffix(rule.suffix) else {
381            continue;
382        };
383        steps.push(AffixStep::Suffix(rule.suffix));
384        for restore in rule.restores {
385            let candidate = format!("{stem}{restore}");
386            if candidate.len() < MIN_ROOT_CHARS
387                || !restoration_is_wellformed(rule.suffix, &candidate)
388            {
389                continue;
390            }
391            if let Some(analysis) = strip_suffixes(&candidate, dictionary, steps) {
392                return Some(analysis);
393            }
394        }
395        steps.pop();
396    }
397    None
398}
399
400/// Whether restoring that ending is orthographically possible for this suffix.
401///
402/// English keeps a stem's final `e` before `-able` when it follows a soft `c` or `g`
403/// (`noticeable`, `changeable`, `manageable`) and drops it otherwise (`movable`,
404/// `provable`). So a restored `e` that lands on `ce` or `ge` says the word should have
405/// kept it, which makes the token a misspelling — `noticable` — rather than a
406/// derivation. Without this the analyzer silences it whenever the engine happens to
407/// offer no correction.
408fn restoration_is_wellformed(suffix: &str, candidate: &str) -> bool {
409    suffix != "able" || !(candidate.ends_with("ce") || candidate.ends_with("ge"))
410}
411
412/// Whether `word` is a word either the workspace or harper already knows.
413///
414/// Harper's curated dictionary is consulted as well as the configured wordlists so that
415/// `semicontinuity` resolves through `continuity` without anybody having to add a
416/// general-English list to the project.
417fn is_known(word: &str, dictionary: Option<&Dictionary>) -> bool {
418    dictionary.is_some_and(|d| d.contains(word)) || CURATED.contains_word_str(word)
419}
420
421pub mod inflection;
422
423#[cfg(test)]
424mod tests;