Skip to main content

harper_core/spell/
fst_dictionary.rs

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
14/// An immutable dictionary allowing for very fast spellchecking.
15///
16/// For dictionaries with changing contents, such as user and file dictionaries, prefer
17/// [`MutableDictionary`].
18pub struct FstDictionary {
19    /// Underlying [`super::MutableDictionary`] used for everything except fuzzy finding
20    mutable_dict: Arc<MutableDictionary>,
21    /// Used for fuzzy-finding the WordId of words or metadata
22    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    // Builders are computationally expensive and do not depend on the word, so we store a
33    // collection of builders and the associated edit distance here.
34    // Currently, the edit distance we use is three, but a value that does not exist in this
35    // collection will create a new builder of that distance and push it to the collection.
36    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    /// Create a dictionary from the curated dictionary included
50    /// in the Harper binary.
51    pub fn curated() -> Arc<Self> {
52        (*DICT).clone()
53    }
54
55    /// Construct a new [`FstDictionary`] using a wordlist as a source.
56    /// This can be expensive, so only use this if fast fuzzy searches are worth it.
57    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    // Insert if it does not exist
84    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
102/// Consumes a DFA stream and emits the WordID-edit distance pairs it produces.
103fn 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
112/// Merges WordID-distance pairs, keeping the smallest distance for each word.
113fn 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        // Actual FST search
154        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        // Merge the two results, keeping the smallest distance when both DFAs match.
159        // The uppercase and lowercase searches can return different result counts, so
160        // we can't simply zip the vectors without losing matches.
161        let mut best_distances = HashMap::<u64, u8>::new();
162
163        merge_best_distances(&mut best_distances, upper_dists);
164
165        // Only build the lowercase DFA when the query is not already lowercase.
166        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        // Ignore exact matches
189        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}