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;