1use std::{
2 borrow::Cow,
3 cell::RefCell,
4 sync::{Arc, LazyLock},
5};
6
7use fst::{IntoStreamer, Map as FstMap, Streamer, map::StreamWithState};
8use hashbrown::HashMap;
9use levenshtein_automata::{DFA, LevenshteinAutomatonBuilder};
10
11use super::{Dictionary, FuzzyMatchResult, MutableDictionary, WordId};
12use crate::{CharString, CharStringExt, DictWordMetadata};
13
14pub struct FstDictionary {
19 mutable_dict: Arc<MutableDictionary>,
21 word_map: FstMap<Vec<u8>>,
23}
24
25const EXPECTED_DISTANCE: u8 = 3;
26const TRANSPOSITION_COST_ONE: bool = true;
27
28static DICT: LazyLock<Arc<FstDictionary>> =
29 LazyLock::new(|| Arc::new((*MutableDictionary::curated()).clone().into()));
30
31thread_local! {
32 static AUTOMATON_BUILDERS: RefCell<Vec<(u8, LevenshteinAutomatonBuilder)>> = RefCell::new(vec![(
37 EXPECTED_DISTANCE,
38 LevenshteinAutomatonBuilder::new(EXPECTED_DISTANCE, TRANSPOSITION_COST_ONE),
39 )]);
40}
41
42impl PartialEq for FstDictionary {
43 fn eq(&self, other: &Self) -> bool {
44 self.mutable_dict == other.mutable_dict
45 }
46}
47
48impl FstDictionary {
49 pub fn curated() -> Arc<Self> {
52 (*DICT).clone()
53 }
54
55 pub fn new(mut words: Vec<(CharString, DictWordMetadata)>) -> Self {
58 words.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
59 words.dedup_by(|(a, _), (b, _)| a == b);
60
61 let mut builder = fst::MapBuilder::memory();
62 for (word_chars, _) in words.iter() {
63 let word = word_chars.iter().collect::<String>();
64 builder
65 .insert(word, WordId::from_word_chars(word_chars).into())
66 .expect("Insertion not in lexicographical order!");
67 }
68
69 let mut mutable_dict = MutableDictionary::new();
70 mutable_dict.extend_words(words.iter().cloned());
71
72 let fst_bytes = builder.into_inner().unwrap();
73 let word_map = FstMap::new(fst_bytes).expect("Unable to build FST map.");
74
75 FstDictionary {
76 mutable_dict: Arc::new(mutable_dict),
77 word_map,
78 }
79 }
80}
81
82fn build_dfa(max_distance: u8, query: &str) -> DFA {
83 AUTOMATON_BUILDERS.with_borrow_mut(|v| {
85 if !v.iter().any(|t| t.0 == max_distance) {
86 v.push((
87 max_distance,
88 LevenshteinAutomatonBuilder::new(max_distance, TRANSPOSITION_COST_ONE),
89 ));
90 }
91 });
92
93 AUTOMATON_BUILDERS.with_borrow(|v| {
94 v.iter()
95 .find(|a| a.0 == max_distance)
96 .unwrap()
97 .1
98 .build_dfa(query)
99 })
100}
101
102fn stream_distances_vec(stream: &mut StreamWithState<&DFA>, dfa: &DFA) -> Vec<(u64, u8)> {
104 let mut word_id_pairs = Vec::new();
105 while let Some((_, v, s)) = stream.next() {
106 word_id_pairs.push((v, dfa.distance(s).to_u8()));
107 }
108
109 word_id_pairs
110}
111
112fn merge_best_distances(
114 best_distances: &mut HashMap<u64, u8>,
115 distances: impl IntoIterator<Item = (u64, u8)>,
116) {
117 for (word_id, dist) in distances {
118 best_distances
119 .entry(word_id)
120 .and_modify(|existing| *existing = (*existing).min(dist))
121 .or_insert(dist);
122 }
123}
124
125impl Dictionary for FstDictionary {
126 fn contains_word(&self, word: &[char]) -> bool {
127 self.mutable_dict.contains_word(word)
128 }
129
130 fn contains_word_str(&self, word: &str) -> bool {
131 self.mutable_dict.contains_word_str(word)
132 }
133
134 fn get_word_metadata(&self, word: &[char]) -> Option<Cow<'_, DictWordMetadata>> {
135 self.mutable_dict.get_word_metadata(word)
136 }
137
138 fn get_word_metadata_str(&self, word: &str) -> Option<Cow<'_, DictWordMetadata>> {
139 self.mutable_dict.get_word_metadata_str(word)
140 }
141
142 fn fuzzy_match(
143 &'_ self,
144 word: &[char],
145 max_distance: u8,
146 max_results: usize,
147 ) -> Vec<FuzzyMatchResult<'_>> {
148 let misspelled_word_charslice = word.normalized();
149 let misspelled_word_string = misspelled_word_charslice.to_string();
150 let misspelled_lower = misspelled_word_string.to_lowercase();
151 let is_already_lower = misspelled_lower == misspelled_word_string;
152
153 let dfa = build_dfa(max_distance, &misspelled_word_string);
155 let mut word_ids_stream = self.word_map.search_with_state(&dfa).into_stream();
156 let upper_dists = stream_distances_vec(&mut word_ids_stream, &dfa);
157
158 let mut best_distances = HashMap::<u64, u8>::new();
162
163 merge_best_distances(&mut best_distances, upper_dists);
164
165 if !is_already_lower {
167 let dfa_lowercase = build_dfa(max_distance, &misspelled_lower);
168 let mut word_ids_lowercase_stream = self
169 .word_map
170 .search_with_state(&dfa_lowercase)
171 .into_stream();
172 let lower_dists = stream_distances_vec(&mut word_ids_lowercase_stream, &dfa_lowercase);
173
174 merge_best_distances(&mut best_distances, lower_dists);
175 }
176
177 let mut merged = Vec::with_capacity(best_distances.len());
178 for (word_id, edit_distance) in best_distances {
179 let word = self.mutable_dict.get_word_from_id(&word_id.into()).unwrap();
180 let metadata = self.mutable_dict.get_word_metadata(word).unwrap();
181 merged.push(FuzzyMatchResult {
182 word,
183 edit_distance,
184 metadata,
185 });
186 }
187
188 merged.retain(|v| v.edit_distance > 0);
190 merged.sort_unstable_by(|a, b| {
191 a.edit_distance
192 .cmp(&b.edit_distance)
193 .then_with(|| a.word.cmp(b.word))
194 });
195 merged.truncate(max_results);
196
197 merged
198 }
199
200 fn fuzzy_match_str(
201 &'_ self,
202 word: &str,
203 max_distance: u8,
204 max_results: usize,
205 ) -> Vec<FuzzyMatchResult<'_>> {
206 self.fuzzy_match(
207 word.chars().collect::<Vec<_>>().as_slice(),
208 max_distance,
209 max_results,
210 )
211 }
212
213 fn words_iter(&self) -> Box<dyn Iterator<Item = &'_ [char]> + Send + '_> {
214 self.mutable_dict.words_iter()
215 }
216
217 fn word_count(&self) -> usize {
218 self.mutable_dict.word_count()
219 }
220
221 fn contains_exact_word(&self, word: &[char]) -> bool {
222 self.mutable_dict.contains_exact_word(word)
223 }
224
225 fn contains_exact_word_str(&self, word: &str) -> bool {
226 self.mutable_dict.contains_exact_word_str(word)
227 }
228
229 fn get_correct_capitalization_of(&self, word: &[char]) -> Option<&'_ [char]> {
230 self.mutable_dict.get_correct_capitalization_of(word)
231 }
232
233 fn get_word_from_id(&self, id: &WordId) -> Option<&[char]> {
234 self.mutable_dict.get_word_from_id(id)
235 }
236
237 fn find_words_with_prefix(&self, prefix: &[char]) -> Vec<Cow<'_, [char]>> {
238 self.mutable_dict.find_words_with_prefix(prefix)
239 }
240
241 fn find_words_with_common_prefix(&self, word: &[char]) -> Vec<Cow<'_, [char]>> {
242 self.mutable_dict.find_words_with_common_prefix(word)
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use itertools::Itertools;
249
250 use crate::CharStringExt;
251 use crate::DictWordMetadata;
252 use crate::spell::{Dictionary, MutableDictionary, WordId};
253
254 use super::FstDictionary;
255
256 fn test_dictionaries(words: &[&str]) -> (MutableDictionary, FstDictionary) {
257 let mut mutable = MutableDictionary::new();
258
259 for word in words {
260 mutable.append_word_str(word, DictWordMetadata::default());
261 }
262
263 let fst = FstDictionary::from(mutable.clone());
264
265 (mutable, fst)
266 }
267
268 fn fuzzy_matches<D: Dictionary + ?Sized>(
269 dict: &D,
270 word: &str,
271 max_distance: u8,
272 max_results: usize,
273 ) -> Vec<(String, u8)> {
274 let mut matches = dict
275 .fuzzy_match_str(word, max_distance, max_results)
276 .into_iter()
277 .map(|result| (result.word.iter().collect::<String>(), result.edit_distance))
278 .collect_vec();
279
280 matches.sort_unstable_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
281 matches
282 }
283
284 #[test]
285 fn damerau_transposition_costs_one() {
286 let lev_automata =
287 levenshtein_automata::LevenshteinAutomatonBuilder::new(1, true).build_dfa("woof");
288 assert_eq!(
289 lev_automata.eval("wofo"),
290 levenshtein_automata::Distance::Exact(1)
291 );
292 }
293
294 #[test]
295 fn damerau_transposition_costs_two() {
296 let lev_automata =
297 levenshtein_automata::LevenshteinAutomatonBuilder::new(1, false).build_dfa("woof");
298 assert_eq!(
299 lev_automata.eval("wofo"),
300 levenshtein_automata::Distance::AtLeast(2)
301 );
302 }
303
304 #[test]
305 fn fst_map_contains_all_in_mutable_dict() {
306 let dict = FstDictionary::curated();
307
308 for word in dict.words_iter() {
309 let misspelled_normalized = word.normalized();
310 let misspelled_word = misspelled_normalized.to_string();
311 let misspelled_lower = misspelled_normalized.to_lower().to_string();
312
313 dbg!(&misspelled_lower);
314
315 assert!(!misspelled_word.is_empty());
316 assert!(dict.word_map.contains_key(misspelled_word));
317 }
318 }
319
320 #[test]
321 fn fst_contains_hello() {
322 let dict = FstDictionary::curated();
323
324 let word: Vec<_> = "hello".chars().collect();
325 let misspelled_normalized = word.normalized();
326 let misspelled_word = misspelled_normalized.to_string();
327 let misspelled_lower = misspelled_normalized.to_lower().to_string();
328
329 assert!(dict.contains_word(&misspelled_normalized));
330 assert!(
331 dict.word_map.contains_key(misspelled_lower)
332 || dict.word_map.contains_key(misspelled_word)
333 );
334 }
335
336 #[test]
337 fn on_is_not_nominal() {
338 let dict = FstDictionary::curated();
339
340 assert!(!dict.get_word_metadata_str("on").unwrap().is_nominal());
341 }
342
343 #[test]
344 fn dickens_is_not_a_swear_1656() {
345 let dict = FstDictionary::curated();
346 let metadata = dict.get_word_metadata_str("Dickens").unwrap();
347
348 assert!(metadata.is_proper_noun());
349 assert!(!metadata.is_swear());
350 assert!(metadata.derived_from.is_none());
351 assert!(dict.contains_exact_word_str("dickens"));
352 assert!(dict.get_word_metadata_str("dick").unwrap().is_swear());
353 }
354
355 #[test]
356 fn fuzzy_result_sorted_by_edit_distance() {
357 let dict = FstDictionary::curated();
358
359 let results = dict.fuzzy_match_str("hello", 3, 100);
360 let is_sorted_by_dist = results
361 .iter()
362 .map(|fm| fm.edit_distance)
363 .tuple_windows()
364 .all(|(a, b)| a <= b);
365
366 assert!(is_sorted_by_dist)
367 }
368
369 #[test]
370 fn contractions_not_derived() {
371 let dict = FstDictionary::curated();
372
373 let contractions = ["there's", "we're", "here's"];
374
375 for contraction in contractions {
376 dbg!(contraction);
377 assert!(
378 dict.get_word_metadata_str(contraction)
379 .unwrap()
380 .derived_from
381 .is_none()
382 )
383 }
384 }
385
386 #[test]
387 fn plural_llamas_derived_from_llama() {
388 let dict = FstDictionary::curated();
389
390 assert_eq!(
391 dict.get_word_metadata_str("llamas")
392 .unwrap()
393 .derived_from
394 .unwrap(),
395 WordId::from_word_str("llama")
396 )
397 }
398
399 #[test]
400 fn plural_cats_derived_from_cat() {
401 let dict = FstDictionary::curated();
402
403 assert_eq!(
404 dict.get_word_metadata_str("cats")
405 .unwrap()
406 .derived_from
407 .unwrap(),
408 WordId::from_word_str("cat")
409 );
410 }
411
412 #[test]
413 fn unhappy_derived_from_happy() {
414 let dict = FstDictionary::curated();
415
416 assert_eq!(
417 dict.get_word_metadata_str("unhappy")
418 .unwrap()
419 .derived_from
420 .unwrap(),
421 WordId::from_word_str("happy")
422 );
423 }
424
425 #[test]
426 fn quickly_derived_from_quick() {
427 let dict = FstDictionary::curated();
428
429 assert_eq!(
430 dict.get_word_metadata_str("quickly")
431 .unwrap()
432 .derived_from
433 .unwrap(),
434 WordId::from_word_str("quick")
435 );
436 }
437
438 #[test]
439 fn lowercase_fuzzy_match_matches_mutable_dictionary() {
440 let (mutable, fst) =
441 test_dictionaries(&["spelling", "spilling", "selling", "smelling", "shelling"]);
442
443 let mutable_results = fuzzy_matches(&mutable, "speling", 3, 10);
444 let fst_results = fuzzy_matches(&fst, "speling", 3, 10);
445
446 assert_eq!(fst_results, mutable_results);
447 assert_eq!(fst_results.first(), Some(&(String::from("spelling"), 1)));
448 }
449
450 #[test]
451 fn capitalized_fuzzy_match_matches_mutable_dictionary() {
452 let (mutable, fst) =
453 test_dictionaries(&["spelling", "spilling", "selling", "smelling", "shelling"]);
454
455 let mutable_results = fuzzy_matches(&mutable, "Speling", 3, 10);
456 let fst_results = fuzzy_matches(&fst, "Speling", 3, 10);
457
458 assert_eq!(fst_results, mutable_results);
459 assert_eq!(fst_results.first(), Some(&(String::from("spelling"), 1)));
460 }
461
462 #[test]
463 fn uppercase_fuzzy_match_matches_mutable_dictionary() {
464 let (mutable, fst) =
465 test_dictionaries(&["spelling", "spilling", "selling", "smelling", "shelling"]);
466
467 let mutable_results = fuzzy_matches(&mutable, "SPELING", 3, 10);
468 let fst_results = fuzzy_matches(&fst, "SPELING", 3, 10);
469
470 assert_eq!(fst_results, mutable_results);
471 assert_eq!(fst_results.first(), Some(&(String::from("spelling"), 1)));
472 }
473
474 #[test]
475 fn query_casing_produces_the_same_fuzzy_matches() {
476 let (_, fst) =
477 test_dictionaries(&["spelling", "spilling", "selling", "smelling", "shelling"]);
478
479 let lowercase = fuzzy_matches(&fst, "speling", 3, 10);
480 let capitalized = fuzzy_matches(&fst, "Speling", 3, 10);
481 let uppercase = fuzzy_matches(&fst, "SPELING", 3, 10);
482
483 assert_eq!(lowercase, capitalized);
484 assert_eq!(lowercase, uppercase);
485 }
486}