Skip to main content

lang_check/engines/
hunspell.rs

1//! Spelling for the languages no other engine here reads.
2//!
3//! Harper is English-only and `LanguageTool` covers about forty languages,
4//! which leaves real gaps: no Hebrew, no Latin, no Old English. Hunspell
5//! dictionaries exist for all of them and for a long tail besides, so this
6//! engine reads that format and fills the gap with spelling -- and only
7//! spelling. There are no grammar rules here, and a language served by this
8//! engine alone gets a narrower check than one `LanguageTool` supports. That
9//! is still the difference between checked and unchecked.
10//!
11//! The dictionaries are not bundled and cannot be; see [`crate::packs`].
12//! Parsing is [`spellbook`], a Rust implementation of Nuspell, so nothing here
13//! links a C library and the release binaries stay static.
14
15use std::collections::HashMap;
16
17use anyhow::Result;
18
19use crate::checker::{Diagnostic, Severity};
20use crate::packs::{PackError, PackRegistry, ResolvedPack};
21
22/// How many suggestions to ask for.
23///
24/// Hunspell suggestion generation is far more expensive than the lookup that
25/// found the misspelling, and a list longer than this is not read -- the
26/// editor shows the first few and the rest are scrolled past.
27const MAX_SUGGESTIONS: usize = 8;
28
29/// A loaded dictionary, and where it came from.
30struct LoadedPack {
31    dictionary: spellbook::Dictionary,
32    pack: ResolvedPack,
33}
34
35/// Checks spelling against Hunspell dictionaries, one per language.
36pub struct HunspellEngine {
37    registry: PackRegistry,
38    /// Languages this engine is allowed to answer for. Empty means any
39    /// language with a pack behind it.
40    languages: Vec<String>,
41    /// Loaded on first use and kept: a 7.8 MB Hebrew dictionary parses in
42    /// about 60 ms, which is once per session rather than once per keystroke.
43    loaded: HashMap<String, LoadedPack>,
44    /// Languages already found to have no usable pack, so a document full of
45    /// them does not re-walk the filesystem for every range.
46    failed: HashMap<String, String>,
47}
48
49impl HunspellEngine {
50    #[must_use]
51    pub fn new(registry: PackRegistry, languages: Vec<String>) -> Self {
52        Self {
53            registry,
54            languages,
55            loaded: HashMap::new(),
56            failed: HashMap::new(),
57        }
58    }
59
60    /// The pack for `language`, loading it if this is the first ask.
61    fn dictionary(&mut self, language: &str) -> Result<&LoadedPack, PackError> {
62        let key = language.to_ascii_lowercase();
63        if let Some(reason) = self.failed.get(&key) {
64            return Err(PackError::Unreadable {
65                path: std::path::PathBuf::from(language),
66                detail: reason.clone(),
67            });
68        }
69        if !self.loaded.contains_key(&key) {
70            let loaded = self.load(language)?;
71            self.loaded.insert(key.clone(), loaded);
72        }
73        Ok(&self.loaded[&key])
74    }
75
76    fn load(&mut self, language: &str) -> Result<LoadedPack, PackError> {
77        let pack = self.registry.resolve(language)?;
78        let aff = std::fs::read_to_string(&pack.aff).map_err(|e| PackError::Unreadable {
79            path: pack.aff.clone(),
80            detail: e.to_string(),
81        })?;
82        let dic = std::fs::read_to_string(&pack.dic).map_err(|e| PackError::Unreadable {
83            path: pack.dic.clone(),
84            detail: e.to_string(),
85        })?;
86
87        match spellbook::Dictionary::new(&aff, &dic) {
88            Ok(dictionary) => Ok(LoadedPack { dictionary, pack }),
89            Err(e) => {
90                // Remembered, because a pack that will not parse will not
91                // parse on the next range either, and re-reading 7 MB to
92                // rediscover that is the difference between a slow check and
93                // an unusable one.
94                let detail = e.to_string();
95                self.failed
96                    .insert(language.to_ascii_lowercase(), detail.clone());
97                Err(PackError::Malformed {
98                    path: pack.aff,
99                    detail,
100                })
101            }
102        }
103    }
104
105    /// Which languages currently have a usable pack, for the inspector.
106    #[must_use]
107    pub fn available(&self) -> Vec<ResolvedPack> {
108        self.registry.installed()
109    }
110}
111
112/// Split prose into the words a speller should judge, with byte offsets.
113///
114/// Not `split_whitespace`: a word carries punctuation that is not part of it,
115/// and an apostrophe or a hyphen inside one is. Digits end a word's candidacy
116/// outright -- `v0.5.3`, `3rd` and `A4` are not misspellings and a dictionary
117/// has no opinion worth hearing about them.
118fn words(text: &str) -> Vec<(usize, &str)> {
119    let mut out = Vec::new();
120    let mut run_start: Option<usize> = None;
121    let mut has_digit = false;
122
123    for (offset, ch) in text.char_indices() {
124        let joins = matches!(ch, '\'' | '\u{2019}' | '-' | '\u{2010}')
125            && run_start.is_some()
126            && text[offset + ch.len_utf8()..]
127                .chars()
128                .next()
129                .is_some_and(char::is_alphanumeric);
130        if ch.is_alphanumeric() || joins {
131            run_start.get_or_insert(offset);
132            has_digit |= ch.is_numeric();
133        } else if let Some(from) = run_start.take() {
134            push_run(text, from, offset, has_digit, &mut out);
135            has_digit = false;
136        }
137    }
138    if let Some(from) = run_start {
139        push_run(text, from, text.len(), has_digit, &mut out);
140    }
141    out
142}
143
144/// Record one run as a candidate, unless a digit disqualified it.
145///
146/// The run's edges are trimmed of anything not a letter, so a trailing
147/// apostrophe in `dogs'` is dropped while the one in `don't` is kept.
148fn push_run<'a>(
149    text: &'a str,
150    from: usize,
151    to: usize,
152    has_digit: bool,
153    out: &mut Vec<(usize, &'a str)>,
154) {
155    if has_digit {
156        return;
157    }
158    let run = &text[from..to];
159    let trimmed = run.trim_matches(|c: char| !c.is_alphabetic());
160    if trimmed.is_empty() {
161        return;
162    }
163    let lead = run.len() - run.trim_start_matches(|c: char| !c.is_alphabetic()).len();
164    out.push((from + lead, trimmed));
165}
166
167#[async_trait::async_trait]
168impl super::Engine for HunspellEngine {
169    fn name(&self) -> &'static str {
170        "hunspell"
171    }
172
173    fn supported_languages(&self) -> Vec<String> {
174        // Declared per installation rather than compiled in, so the engine
175        // cannot advertise a language whose pack is not there. An empty list
176        // is the wildcard the orchestrator already understands, and the
177        // per-language load below is what actually decides.
178        Vec::new()
179    }
180
181    async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
182        // An explicit list means "only these", so a deployment can keep this
183        // engine to the gaps and leave English to Harper.
184        if !self.languages.is_empty() {
185            let primary = language_id.split('-').next().unwrap_or(language_id);
186            let wanted = self.languages.iter().any(|l| {
187                let configured = l.split('-').next().unwrap_or(l);
188                configured.eq_ignore_ascii_case(primary)
189            });
190            if !wanted {
191                return Err(anyhow::Error::new(super::UnsupportedLanguage {
192                    engine: "hunspell",
193                    language: language_id.to_string(),
194                }));
195            }
196        }
197
198        let loaded = match self.dictionary(language_id) {
199            Ok(loaded) => loaded,
200            // No pack is not a failure of this engine, it is this engine
201            // having nothing to say about this language -- which is what the
202            // orchestrator reports as no-provider rather than as a fault.
203            Err(e) if e.is_installable() => {
204                return Err(anyhow::Error::new(super::UnsupportedLanguage {
205                    engine: "hunspell",
206                    language: language_id.to_string(),
207                }));
208            }
209            Err(e) => return Err(anyhow::anyhow!("{e}")),
210        };
211
212        let mut diagnostics = Vec::new();
213        for (offset, word) in words(text) {
214            if loaded.dictionary.check(word) {
215                continue;
216            }
217            let mut suggestions = Vec::new();
218            loaded.dictionary.suggest(word, &mut suggestions);
219            suggestions.truncate(MAX_SUGGESTIONS);
220
221            #[allow(clippy::cast_possible_truncation)]
222            diagnostics.push(Diagnostic {
223                start_byte: offset as u32,
224                end_byte: (offset + word.len()) as u32,
225                message: format!("\"{word}\" is not in the {} dictionary", loaded.pack.stem),
226                suggestions,
227                rule_id: "hunspell.spelling".to_string(),
228                severity: Severity::Warning as i32,
229                unified_id: String::new(), // Will be filled by normalizer
230                // Below Harper and LanguageTool on purpose: a wordlist with no
231                // grammar behind it cannot tell a coinage from a typo.
232                confidence: 0.6,
233                language: String::new(),
234                pack_installable: false,
235            });
236        }
237        Ok(diagnostics)
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::engines::Engine;
245
246    /// `(byte offset, word)` for each candidate the speller would judge.
247    fn candidates(text: &str) -> Vec<(usize, &str)> {
248        words(text)
249    }
250
251    #[test]
252    fn plain_words_are_found_with_their_offsets() {
253        assert_eq!(candidates("one two"), vec![(0, "one"), (4, "two")]);
254    }
255
256    #[test]
257    fn punctuation_is_not_part_of_a_word() {
258        assert_eq!(
259            candidates("Hello, world! Yes."),
260            vec![(0, "Hello"), (7, "world"), (14, "Yes")]
261        );
262    }
263
264    #[test]
265    fn an_apostrophe_inside_a_word_stays() {
266        // `dont` and `don't` are different questions for a speller, and
267        // splitting on the apostrophe asks the wrong one.
268        assert_eq!(candidates("don't"), vec![(0, "don't")]);
269        assert_eq!(
270            candidates("l\u{2019}autorit\u{e9}"),
271            vec![(0, "l\u{2019}autorit\u{e9}")]
272        );
273    }
274
275    #[test]
276    fn a_quote_around_a_word_does_not() {
277        assert_eq!(candidates("'quoted'"), vec![(1, "quoted")]);
278    }
279
280    #[test]
281    fn a_hyphenated_word_stays_whole() {
282        assert_eq!(candidates("well-known"), vec![(0, "well-known")]);
283    }
284
285    #[test]
286    fn anything_with_a_digit_is_not_a_word() {
287        // A dictionary has no useful opinion about a version or an identifier.
288        assert!(candidates("v0").is_empty(), "{:?}", candidates("v0"));
289        assert!(candidates("A4").is_empty(), "{:?}", candidates("A4"));
290        assert_eq!(candidates("the 3rd time"), vec![(0, "the"), (8, "time")]);
291    }
292
293    #[test]
294    fn offsets_survive_multibyte_text() {
295        // Hebrew and French are the point of this engine; an offset that
296        // counts characters puts every underline in the wrong place.
297        let text = "caf\u{e9} na\u{ef}ve";
298        let found = candidates(text);
299        assert_eq!(found.len(), 2);
300        for (offset, word) in found {
301            assert_eq!(&text[offset..offset + word.len()], word);
302        }
303    }
304
305    #[test]
306    fn hebrew_is_tokenised_by_word() {
307        let text = "\u{5e9}\u{5dc}\u{5d5}\u{5dd} \u{5e2}\u{5d5}\u{5dc}\u{5dd}";
308        let found = candidates(text);
309        assert_eq!(found.len(), 2, "{found:?}");
310        for (offset, word) in found {
311            assert_eq!(&text[offset..offset + word.len()], word);
312        }
313    }
314
315    #[test]
316    fn empty_and_punctuation_only_text_yields_nothing() {
317        assert_eq!(candidates(""), Vec::new());
318        assert_eq!(candidates("--- ... !!!"), Vec::new());
319    }
320
321    /// A tiny real dictionary, so the engine is exercised end to end rather
322    /// than mocked.
323    fn tiny_pack(dir: &std::path::Path, stem: &str, words: &[&str]) {
324        std::fs::write(dir.join(format!("{stem}.aff")), "SET UTF-8\n").unwrap();
325        let mut body = String::new();
326        for word in words {
327            use std::fmt::Write as _;
328            let _ = writeln!(body, "{word}");
329        }
330        std::fs::write(
331            dir.join(format!("{stem}.dic")),
332            format!("{}\n{body}", words.len()),
333        )
334        .unwrap();
335    }
336
337    fn engine_over(dir: &std::path::Path, languages: Vec<String>) -> HunspellEngine {
338        let registry = PackRegistry::new().with_only_search_paths(vec![dir.to_path_buf()]);
339        HunspellEngine::new(registry, languages)
340    }
341
342    #[tokio::test]
343    async fn a_word_outside_the_dictionary_is_reported_with_its_offset() {
344        let dir = tempfile::tempdir().unwrap();
345        tiny_pack(dir.path(), "xx", &["alpha", "beta"]);
346        let mut engine = engine_over(dir.path(), Vec::new());
347
348        let text = "alpha gamma beta";
349        let found = engine.check(text, "xx").await.unwrap();
350        assert_eq!(found.len(), 1, "{found:?}");
351        assert_eq!(found[0].start_byte, 6);
352        assert_eq!(found[0].end_byte, 11);
353        assert_eq!(&text[6..11], "gamma");
354        assert_eq!(found[0].rule_id, "hunspell.spelling");
355    }
356
357    #[tokio::test]
358    async fn a_clean_sentence_reports_nothing() {
359        let dir = tempfile::tempdir().unwrap();
360        tiny_pack(dir.path(), "xx", &["alpha", "beta"]);
361        let mut engine = engine_over(dir.path(), Vec::new());
362        assert_eq!(engine.check("alpha beta", "xx").await.unwrap(), Vec::new());
363    }
364
365    #[tokio::test]
366    async fn a_language_with_no_pack_is_declined_not_failed() {
367        // The orchestrator turns this into the no-provider diagnostic and
368        // leaves engine health alone; reporting it as an error would say the
369        // spell checker is broken when it simply has no Hebrew.
370        let dir = tempfile::tempdir().unwrap();
371        tiny_pack(dir.path(), "xx", &["alpha"]);
372        let mut engine = engine_over(dir.path(), Vec::new());
373
374        let err = engine.check("shalom", "he").await.unwrap_err();
375        assert!(
376            crate::engines::is_unsupported_language::<Vec<Diagnostic>>(&Err(err)),
377            "a missing pack must read as unsupported, not as a failure"
378        );
379    }
380
381    #[tokio::test]
382    async fn a_malformed_pack_is_an_error_and_is_only_read_once() {
383        // The 2013 Latin dictionary really does say SFK where SFX belongs.
384        let dir = tempfile::tempdir().unwrap();
385        std::fs::write(
386            dir.path().join("xx.aff"),
387            "SET UTF-8\nSFX k Y 129\nSFK k idis idos idis\n",
388        )
389        .unwrap();
390        std::fs::write(dir.path().join("xx.dic"), "1\nalpha\n").unwrap();
391        let mut engine = engine_over(dir.path(), Vec::new());
392
393        let first = engine.check("alpha", "xx").await.unwrap_err();
394        assert!(
395            !crate::engines::is_unsupported_language::<Vec<Diagnostic>>(&Err(first)),
396            "a broken pack is a fault, not an absent language"
397        );
398        // Deleting the files proves the second call never touched the disk.
399        std::fs::remove_file(dir.path().join("xx.aff")).unwrap();
400        assert!(engine.check("alpha", "xx").await.is_err());
401    }
402
403    #[tokio::test]
404    async fn a_language_outside_the_configured_list_is_declined() {
405        let dir = tempfile::tempdir().unwrap();
406        tiny_pack(dir.path(), "xx", &["alpha"]);
407        tiny_pack(dir.path(), "en", &["alpha"]);
408        let mut engine = engine_over(dir.path(), vec!["xx".to_string()]);
409
410        assert!(engine.check("alpha", "xx").await.is_ok());
411        let err = engine.check("alpha", "en-GB").await.unwrap_err();
412        assert!(
413            crate::engines::is_unsupported_language::<Vec<Diagnostic>>(&Err(err)),
414            "an unlisted language must be declined, leaving it to Harper"
415        );
416    }
417
418    #[tokio::test]
419    async fn a_regional_tag_matches_a_configured_primary_subtag() {
420        let dir = tempfile::tempdir().unwrap();
421        tiny_pack(dir.path(), "en_GB", &["alpha"]);
422        let mut engine = engine_over(dir.path(), vec!["en".to_string()]);
423        assert!(engine.check("alpha", "en-GB").await.is_ok());
424    }
425}