1use std::collections::HashMap;
16
17use anyhow::Result;
18
19use crate::checker::{Diagnostic, Severity};
20use crate::packs::{PackError, PackRegistry, ResolvedPack};
21
22const MAX_SUGGESTIONS: usize = 8;
28
29struct LoadedPack {
31 dictionary: spellbook::Dictionary,
32 pack: ResolvedPack,
33}
34
35pub struct HunspellEngine {
37 registry: PackRegistry,
38 languages: Vec<String>,
41 loaded: HashMap<String, LoadedPack>,
44 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 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 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 #[must_use]
107 pub fn available(&self) -> Vec<ResolvedPack> {
108 self.registry.installed()
109 }
110}
111
112fn 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
144fn 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 Vec::new()
179 }
180
181 async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
182 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 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(), 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 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 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 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 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 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 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 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 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}