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 fuzzy_result_sorted_by_edit_distance() {
345 let dict = FstDictionary::curated();
346
347 let results = dict.fuzzy_match_str("hello", 3, 100);
348 let is_sorted_by_dist = results
349 .iter()
350 .map(|fm| fm.edit_distance)
351 .tuple_windows()
352 .all(|(a, b)| a <= b);
353
354 assert!(is_sorted_by_dist)
355 }
356
357 #[test]
358 fn contractions_not_derived() {
359 let dict = FstDictionary::curated();
360
361 let contractions = ["there's", "we're", "here's"];
362
363 for contraction in contractions {
364 dbg!(contraction);
365 assert!(
366 dict.get_word_metadata_str(contraction)
367 .unwrap()
368 .derived_from
369 .is_none()
370 )
371 }
372 }
373
374 #[test]
375 fn plural_llamas_derived_from_llama() {
376 let dict = FstDictionary::curated();
377
378 assert_eq!(
379 dict.get_word_metadata_str("llamas")
380 .unwrap()
381 .derived_from
382 .unwrap(),
383 WordId::from_word_str("llama")
384 )
385 }
386
387 #[test]
388 fn plural_cats_derived_from_cat() {
389 let dict = FstDictionary::curated();
390
391 assert_eq!(
392 dict.get_word_metadata_str("cats")
393 .unwrap()
394 .derived_from
395 .unwrap(),
396 WordId::from_word_str("cat")
397 );
398 }
399
400 #[test]
401 fn unhappy_derived_from_happy() {
402 let dict = FstDictionary::curated();
403
404 assert_eq!(
405 dict.get_word_metadata_str("unhappy")
406 .unwrap()
407 .derived_from
408 .unwrap(),
409 WordId::from_word_str("happy")
410 );
411 }
412
413 #[test]
414 fn quickly_derived_from_quick() {
415 let dict = FstDictionary::curated();
416
417 assert_eq!(
418 dict.get_word_metadata_str("quickly")
419 .unwrap()
420 .derived_from
421 .unwrap(),
422 WordId::from_word_str("quick")
423 );
424 }
425
426 #[test]
427 fn lowercase_fuzzy_match_matches_mutable_dictionary() {
428 let (mutable, fst) =
429 test_dictionaries(&["spelling", "spilling", "selling", "smelling", "shelling"]);
430
431 let mutable_results = fuzzy_matches(&mutable, "speling", 3, 10);
432 let fst_results = fuzzy_matches(&fst, "speling", 3, 10);
433
434 assert_eq!(fst_results, mutable_results);
435 assert_eq!(fst_results.first(), Some(&(String::from("spelling"), 1)));
436 }
437
438 #[test]
439 fn capitalized_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 uppercase_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 query_casing_produces_the_same_fuzzy_matches() {
464 let (_, fst) =
465 test_dictionaries(&["spelling", "spilling", "selling", "smelling", "shelling"]);
466
467 let lowercase = fuzzy_matches(&fst, "speling", 3, 10);
468 let capitalized = fuzzy_matches(&fst, "Speling", 3, 10);
469 let uppercase = fuzzy_matches(&fst, "SPELING", 3, 10);
470
471 assert_eq!(lowercase, capitalized);
472 assert_eq!(lowercase, uppercase);
473 }
474}