Skip to main content

lang_check/packs/
mod.rs

1//! Finding, naming and validating Hunspell dictionary packs.
2//!
3//! A pack is an `.aff`/`.dic` pair for one language, in the format Hunspell
4//! established and Nuspell, `LibreOffice`, Firefox and every desktop spell
5//! checker reads. `lang-check` ships none of them: Hspell, which supplies
6//! Hebrew, is AGPL-3.0, and the Latin dictionary is GPL, so bundling either
7//! into an MIT binary published to crates.io and the Marketplace is not
8//! something a licence permits. They are installed on request instead, into
9//! the user's own data directory, which is how VS Code, Firefox and
10//! `LibreOffice` handle the same problem.
11//!
12//! Resolution is deliberately generous about where a pack may already be,
13//! because a Linux user who has run `pacman -S hunspell-he` should not be
14//! asked to download a second copy.
15
16pub mod catalogue;
17pub mod install;
18
19use std::fmt;
20use std::path::{Path, PathBuf};
21
22/// Where a resolved pack came from, which decides what the user is told when
23/// it misbehaves: a system pack is the distribution's to fix, a managed one is
24/// ours to re-install.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum PackSource {
27    /// Named outright in `engines.hunspell.dictionary_paths`.
28    Configured,
29    /// Already on the machine, in a directory the platform's spell checkers use.
30    System,
31    /// Installed by `lang-check` into the user data directory.
32    Managed,
33}
34
35impl fmt::Display for PackSource {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.write_str(match self {
38            Self::Configured => "configured",
39            Self::System => "system",
40            Self::Managed => "managed",
41        })
42    }
43}
44
45/// An `.aff`/`.dic` pair that exists on disk.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ResolvedPack {
48    /// The tag asked for, as given: `he`, `la`, `en-GB`.
49    pub language: String,
50    /// The pack's own stem, which is often more specific: `he_IL` for `he`.
51    pub stem: String,
52    pub aff: PathBuf,
53    pub dic: PathBuf,
54    pub source: PackSource,
55}
56
57/// Why a pack could not be used.
58///
59/// Separate variants rather than one string because the caller acts on them
60/// differently: a missing pack is an offer to install, a malformed one is a
61/// bug report against whoever shipped it, and neither should read as "the
62/// spell checker is broken".
63#[derive(Debug)]
64pub enum PackError {
65    /// No `.aff`/`.dic` pair for this language anywhere that was looked.
66    NotFound {
67        language: String,
68        searched: Vec<PathBuf>,
69    },
70    /// Half a pack: one file of the pair is there and the other is not.
71    Incomplete { language: String, missing: PathBuf },
72    /// On disk but unreadable — permissions, a broken symlink, a bad encoding.
73    Unreadable { path: PathBuf, detail: String },
74    /// Readable, and rejected by the parser.
75    ///
76    /// Real dictionaries carry real defects. The 2013 Latin pack has two lines
77    /// reading `SFK` where `SFX` belongs, so its affix header promises 129
78    /// rows and the parser finds 2. Hunspell skips a line it does not
79    /// recognise and Nuspell does not, which is why a pack can work in
80    /// `LibreOffice` and fail here -- and why saying which file and which line
81    /// matters more than the parser's own wording.
82    Malformed { path: PathBuf, detail: String },
83}
84
85impl fmt::Display for PackError {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Self::NotFound { language, searched } => {
89                write!(f, "no Hunspell dictionary for \"{language}\"")?;
90                if !searched.is_empty() {
91                    write!(f, "; looked in ")?;
92                    let shown: Vec<String> =
93                        searched.iter().map(|p| p.display().to_string()).collect();
94                    write!(f, "{}", shown.join(", "))?;
95                }
96                Ok(())
97            }
98            Self::Incomplete { language, missing } => write!(
99                f,
100                "the Hunspell dictionary for \"{language}\" is missing {}; an .aff and a .dic are both needed",
101                missing.display()
102            ),
103            Self::Unreadable { path, detail } => {
104                write!(f, "cannot read {}: {detail}", path.display())
105            }
106            Self::Malformed { path, detail } => write!(
107                f,
108                "{} is not a dictionary this checker can read: {detail}",
109                path.display()
110            ),
111        }
112    }
113}
114
115impl std::error::Error for PackError {}
116
117impl PackError {
118    /// Whether installing a pack would fix this.
119    ///
120    /// The editor offers an install for exactly this case and stays quiet for
121    /// the rest, because re-downloading a pack that is present and broken
122    /// helps nobody.
123    #[must_use]
124    pub const fn is_installable(&self) -> bool {
125        matches!(self, Self::NotFound { .. })
126    }
127}
128
129/// Directories to look in, and the packs found in them.
130#[derive(Debug, Clone, Default)]
131pub struct PackRegistry {
132    /// `language` -> a directory or an `.aff`/`.dic` stem named in config.
133    overrides: Vec<(String, PathBuf)>,
134    /// Searched in order after the overrides.
135    search_paths: Vec<PathBuf>,
136}
137
138/// Where `lang-check` installs packs it fetches.
139///
140/// Beside the workspace databases, under the same `language-check` directory,
141/// so everything the tool writes for a user lives in one place.
142#[must_use]
143pub fn managed_dir() -> Option<PathBuf> {
144    dirs::data_dir().map(|d| d.join("language-check").join("dictionaries"))
145}
146
147/// Directories the platform's own spell checkers keep dictionaries in.
148///
149/// Listed so a pack the user already installed through their package manager
150/// is found rather than downloaded a second time.
151#[must_use]
152pub fn system_dirs() -> Vec<PathBuf> {
153    let mut dirs_out: Vec<PathBuf> = Vec::new();
154
155    #[cfg(target_os = "macos")]
156    {
157        if let Some(home) = dirs::home_dir() {
158            dirs_out.push(home.join("Library/Spelling"));
159        }
160        dirs_out.push(PathBuf::from("/Library/Spelling"));
161        dirs_out.push(PathBuf::from("/System/Library/Spelling"));
162    }
163
164    #[cfg(target_os = "windows")]
165    {
166        // Windows has no shared convention; LibreOffice keeps its own, and a
167        // user who wants one elsewhere names it in config.
168        if let Some(data) = dirs::data_dir() {
169            dirs_out.push(data.join("hunspell"));
170        }
171    }
172
173    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
174    {
175        dirs_out.push(PathBuf::from("/usr/share/hunspell"));
176        dirs_out.push(PathBuf::from("/usr/share/myspell"));
177        dirs_out.push(PathBuf::from("/usr/share/myspell/dicts"));
178        dirs_out.push(PathBuf::from("/usr/local/share/hunspell"));
179        if let Some(home) = dirs::home_dir() {
180            dirs_out.push(home.join(".local/share/hunspell"));
181        }
182    }
183
184    dirs_out
185}
186
187impl PackRegistry {
188    /// A registry searching the managed directory and the system ones.
189    #[must_use]
190    pub fn new() -> Self {
191        let mut search_paths = Vec::new();
192        // The managed directory first: a pack the user asked us to install
193        // beats a stale system one.
194        search_paths.extend(managed_dir());
195        search_paths.extend(system_dirs());
196        Self {
197            overrides: Vec::new(),
198            search_paths,
199        }
200    }
201
202    /// Point one language at a directory or an explicit `.aff`/`.dic` stem.
203    ///
204    /// An override wins over everything, including a pack we installed, so a
205    /// user can pin a dictionary they prefer and know it is the one in use.
206    #[must_use]
207    pub fn with_override(mut self, language: &str, path: impl Into<PathBuf>) -> Self {
208        self.overrides.push((normalise_tag(language), path.into()));
209        self
210    }
211
212    /// Add a directory to search after the overrides and before the defaults.
213    #[must_use]
214    pub fn with_search_path(mut self, path: impl Into<PathBuf>) -> Self {
215        self.search_paths.insert(0, path.into());
216        self
217    }
218
219    /// Replace the default search paths entirely. For tests, and for a
220    /// deployment that wants nothing but what it names.
221    #[must_use]
222    pub fn with_only_search_paths(mut self, paths: Vec<PathBuf>) -> Self {
223        self.search_paths = paths;
224        self
225    }
226
227    /// The directories this registry would look in, in order.
228    #[must_use]
229    pub fn search_paths(&self) -> &[PathBuf] {
230        &self.search_paths
231    }
232
233    /// Find the pack for `language`, or say precisely why there is none.
234    ///
235    /// `language` is a BCP-47 tag. Hunspell packs are named with an underscore
236    /// and are often more specific than the tag asked for -- `he` is shipped
237    /// as `he_IL` -- so an exact match is tried first, then the primary subtag
238    /// alone, then any pack whose primary subtag agrees.
239    /// A registry built the way the Hunspell engine's is.
240    ///
241    /// Shared so the engine and the check cache cannot disagree about which
242    /// packs are in play -- the cache has to look at exactly what the engine
243    /// would find, or it will serve an answer from before a pack was there.
244    #[must_use]
245    pub fn for_hunspell(config: &crate::config::HunspellConfig) -> Self {
246        let mut registry = Self::new();
247        for dir in &config.search_paths {
248            registry = registry.with_search_path(dir);
249        }
250        for (language, path) in &config.dictionary_paths {
251            registry = registry.with_override(language, path);
252        }
253        registry
254    }
255
256    /// A value that changes when the packs available for `languages` do.
257    ///
258    /// Read by the check cache. Installing a dictionary changes neither the
259    /// document nor the config, so without this the stored result from before
260    /// the install still applied -- and a language the user had just made
261    /// readable went on being reported as unreadable until they edited
262    /// something.
263    ///
264    /// Size and modification time are in it as well as the path, so replacing
265    /// a pack in place counts as a change.
266    #[must_use]
267    pub fn fingerprint(&self, languages: &[String]) -> u64 {
268        let mut parts: Vec<String> = Vec::new();
269
270        let describe = |path: &Path| -> String {
271            std::fs::metadata(path).map_or_else(
272                |_| "missing".to_string(),
273                |meta| {
274                    let modified = meta
275                        .modified()
276                        .ok()
277                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
278                        .map_or(0, |d| d.as_secs());
279                    format!("{}:{modified}", meta.len())
280                },
281            )
282        };
283
284        if languages.is_empty() {
285            // Discovery mode: any pack in the managed directory may be used,
286            // so the directory's contents are what matters.
287            if let Some(dir) = managed_dir()
288                && let Ok(entries) = std::fs::read_dir(&dir)
289            {
290                let mut listed: Vec<String> = entries
291                    .flatten()
292                    .map(|entry| {
293                        format!(
294                            "{}={}",
295                            entry.file_name().to_string_lossy(),
296                            describe(&entry.path())
297                        )
298                    })
299                    .collect();
300                listed.sort_unstable();
301                parts.extend(listed);
302            }
303        } else {
304            for language in languages {
305                match self.resolve(language) {
306                    Ok(pack) => parts.push(format!(
307                        "{language}={}|{}|{}",
308                        pack.stem,
309                        describe(&pack.aff),
310                        describe(&pack.dic),
311                    )),
312                    Err(_) => parts.push(format!("{language}=none")),
313                }
314            }
315        }
316
317        crate::hashing::stable_hash(&parts.join("\x1e"))
318    }
319
320    pub fn resolve(&self, language: &str) -> Result<ResolvedPack, PackError> {
321        let tag = normalise_tag(language);
322
323        for (over_lang, path) in &self.overrides {
324            if over_lang != &tag {
325                continue;
326            }
327            return resolve_override(language, path);
328        }
329
330        let mut searched = Vec::new();
331        for dir in &self.search_paths {
332            if !dir.is_dir() {
333                continue;
334            }
335            searched.push(dir.clone());
336            if let Some(stem) = find_stem(dir, &tag) {
337                let source = if managed_dir().is_some_and(|m| dir.starts_with(&m)) {
338                    PackSource::Managed
339                } else {
340                    PackSource::System
341                };
342                return complete_pair(language, dir, &stem, source);
343            }
344        }
345
346        Err(PackError::NotFound {
347            language: language.to_string(),
348            searched,
349        })
350    }
351
352    /// Every language this registry can resolve, for the inspector and for
353    /// `language-check packs list`.
354    #[must_use]
355    pub fn installed(&self) -> Vec<ResolvedPack> {
356        let mut found: Vec<ResolvedPack> = Vec::new();
357        for dir in &self.search_paths {
358            let Ok(entries) = std::fs::read_dir(dir) else {
359                continue;
360            };
361            for entry in entries.flatten() {
362                let path = entry.path();
363                if path.extension().is_some_and(|e| e == "aff")
364                    && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
365                {
366                    let source = if managed_dir().is_some_and(|m| dir.starts_with(&m)) {
367                        PackSource::Managed
368                    } else {
369                        PackSource::System
370                    };
371                    if let Ok(pack) = complete_pair(stem, dir, stem, source)
372                        && !found.iter().any(|p| p.stem == pack.stem)
373                    {
374                        found.push(pack);
375                    }
376                }
377            }
378        }
379        found.sort_by(|a, b| a.stem.cmp(&b.stem));
380        found
381    }
382}
383
384/// `en-GB` and `en_GB` name the same pack; compare them the same way.
385fn normalise_tag(language: &str) -> String {
386    language.replace('-', "_").to_ascii_lowercase()
387}
388
389/// An override may name a directory, or an `.aff`/`.dic` stem, or either file
390/// of the pair. All three are what a user reaches for, so all three work.
391fn resolve_override(language: &str, path: &Path) -> Result<ResolvedPack, PackError> {
392    let tag = normalise_tag(language);
393    if path.is_dir() {
394        return find_stem(path, &tag).map_or_else(
395            || {
396                Err(PackError::NotFound {
397                    language: language.to_string(),
398                    searched: vec![path.to_path_buf()],
399                })
400            },
401            |stem| complete_pair(language, path, &stem, PackSource::Configured),
402        );
403    }
404
405    // A file, or a stem with no extension.
406    let stem_path = if matches!(
407        path.extension().and_then(|e| e.to_str()),
408        Some("aff" | "dic")
409    ) {
410        path.with_extension("")
411    } else {
412        path.to_path_buf()
413    };
414    let dir = stem_path.parent().unwrap_or_else(|| Path::new("."));
415    let stem = stem_path
416        .file_name()
417        .and_then(|s| s.to_str())
418        .unwrap_or_default()
419        .to_string();
420    complete_pair(language, dir, &stem, PackSource::Configured)
421}
422
423/// The best-matching pack stem in `dir`, if one is there.
424fn find_stem(dir: &Path, tag: &str) -> Option<String> {
425    let mut stems: Vec<String> = std::fs::read_dir(dir)
426        .ok()?
427        .flatten()
428        .filter_map(|entry| {
429            let path = entry.path();
430            (path.extension()? == "aff")
431                .then(|| path.file_stem()?.to_str().map(str::to_string))
432                .flatten()
433        })
434        .collect();
435    stems.sort();
436
437    // Exact: `he_il` for `he_il`.
438    if let Some(hit) = stems.iter().find(|s| normalise_tag(s) == tag) {
439        return Some(hit.clone());
440    }
441    // The tag's primary subtag as a whole stem: `la` for `la_la`.
442    let primary = tag.split('_').next().unwrap_or(tag);
443    if let Some(hit) = stems.iter().find(|s| normalise_tag(s) == primary) {
444        return Some(hit.clone());
445    }
446    // Any pack of the same language: `he_il` for `he`. Sorted, so the choice
447    // is the same on every machine rather than whatever the directory yields.
448    stems
449        .iter()
450        .find(|s| {
451            normalise_tag(s)
452                .split('_')
453                .next()
454                .is_some_and(|p| p == primary)
455        })
456        .cloned()
457}
458
459/// Both halves of a pair, or a report of which half is missing.
460fn complete_pair(
461    language: &str,
462    dir: &Path,
463    stem: &str,
464    source: PackSource,
465) -> Result<ResolvedPack, PackError> {
466    let aff = dir.join(format!("{stem}.aff"));
467    let dic = dir.join(format!("{stem}.dic"));
468    for path in [&aff, &dic] {
469        if !path.is_file() {
470            return Err(PackError::Incomplete {
471                language: language.to_string(),
472                missing: path.clone(),
473            });
474        }
475    }
476    Ok(ResolvedPack {
477        language: language.to_string(),
478        stem: stem.to_string(),
479        aff,
480        dic,
481        source,
482    })
483}
484
485/// Something wrong with a pack that does not stop it being used.
486///
487/// Separate from [`PackError`] because the two need opposite handling: an
488/// error means the language goes unchecked, a warning means it is checked and
489/// something about the pack is worth saying out loud.
490#[derive(Debug, Clone, PartialEq, Eq)]
491pub struct PackWarning {
492    pub path: PathBuf,
493    pub detail: String,
494}
495
496impl fmt::Display for PackWarning {
497    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
498        write!(f, "{}: {}", self.path.display(), self.detail)
499    }
500}
501
502/// What validation found.
503#[derive(Debug, Clone)]
504pub struct PackReport {
505    pub pack: ResolvedPack,
506    /// Entries counted in the `.dic`, which is not always what it declares.
507    pub entries: usize,
508    pub warnings: Vec<PackWarning>,
509}
510
511/// How far the declared entry count may drift, in percent, before it is
512/// worth mentioning.
513///
514/// Real dictionaries disagree with their own header: Hspell's Hebrew declares
515/// 469,509 and carries 469,750, the Latin pack declares 129,290 and carries
516/// 129,285. Hunspell treats the number as a hint for sizing a table, so this
517/// cannot be an invariant -- enforcing it would reject both. A large gap still
518/// suggests a truncated download, which is the case worth catching.
519const COUNT_DRIFT_PERCENT: usize = 5;
520
521/// How many `.dic` entries to feed back through the loaded dictionary.
522///
523/// The parser accepting a file does not mean the affix rules survived it. A
524/// word taken from the dictionary's own list must be spelled correctly by the
525/// dictionary that contains it; if it is not, something is wrong that no
526/// amount of structural checking would have found.
527const SELFTEST_SAMPLE: usize = 64;
528
529/// Check a pack completely, before anything depends on it.
530///
531/// Run at install time rather than at first use, so a pack that downloads
532/// cleanly and then will not load fails while the user is looking at the
533/// install rather than three keystrokes into a paragraph.
534///
535/// # Errors
536///
537/// Returns [`PackError`] when the pack cannot be used at all: a path that is
538/// not a readable file, a `.dic` without its entry count, a file the parser
539/// rejects, or a dictionary that misspells its own entries.
540pub fn validate(pack: &ResolvedPack) -> Result<PackReport, PackError> {
541    let mut warnings = Vec::new();
542
543    let aff = read_pack_file(&pack.aff)?;
544    let dic = read_pack_file(&pack.dic)?;
545
546    // The .dic opens with its entry count. A file that does not is either not
547    // a dictionary or has lost its head.
548    let mut lines = dic.lines();
549    let header = lines
550        .next()
551        .unwrap_or_default()
552        .trim_start_matches('\u{feff}');
553    let declared: usize = header
554        .split_whitespace()
555        .next()
556        .unwrap_or("")
557        .parse()
558        .map_err(|_| PackError::Malformed {
559            path: pack.dic.clone(),
560            detail: format!(
561                "the first line should be the entry count, and reads {:?}",
562                header.chars().take(40).collect::<String>()
563            ),
564        })?;
565
566    let entries: Vec<&str> = lines.filter(|l| !l.trim().is_empty()).collect();
567    let counted = entries.len();
568    if declared > 0 {
569        // Integer ratio, so no cast has to be justified for counts that can
570        // reach the hundreds of thousands.
571        let gap = counted.abs_diff(declared);
572        if gap * 100 > declared * COUNT_DRIFT_PERCENT {
573            warnings.push(PackWarning {
574                path: pack.dic.clone(),
575                detail: format!(
576                    "declares {declared} entries and carries {counted}; \
577                     the file may be truncated"
578                ),
579            });
580        }
581    }
582
583    // Encoding is declared in the .aff. Without it Hunspell assumes Latin-1,
584    // which is wrong for every language this engine exists to serve.
585    if !aff.lines().any(|l| l.trim_start().starts_with("SET ")) {
586        warnings.push(PackWarning {
587            path: pack.aff.clone(),
588            detail: "no SET line, so the encoding is assumed rather than declared".to_string(),
589        });
590    }
591
592    let dictionary = spellbook::Dictionary::new(&aff, &dic).map_err(|e| PackError::Malformed {
593        path: pack.aff.clone(),
594        detail: e.to_string(),
595    })?;
596
597    // The dictionary must agree with itself.
598    let step = (counted / SELFTEST_SAMPLE).max(1);
599    let mut checked = 0usize;
600    let mut rejected = Vec::new();
601    for entry in entries.iter().step_by(step).take(SELFTEST_SAMPLE) {
602        // An entry is `word/FLAGS`, sometimes with a morphological field after
603        // a tab; only the stem is a word.
604        let word = entry.split(['/', '\t']).next().unwrap_or_default().trim();
605        if word.is_empty() || word.starts_with('#') {
606            continue;
607        }
608        checked += 1;
609        if !dictionary.check(word) {
610            rejected.push(word.to_string());
611        }
612    }
613    if checked > 0 && rejected.len() * 2 > checked {
614        return Err(PackError::Malformed {
615            path: pack.dic.clone(),
616            detail: format!(
617                "the dictionary rejects its own entries ({} of {checked} sampled, \
618                 including {:?}); the affix rules do not match the word list",
619                rejected.len(),
620                rejected.iter().take(3).collect::<Vec<_>>()
621            ),
622        });
623    }
624
625    Ok(PackReport {
626        pack: pack.clone(),
627        entries: counted,
628        warnings,
629    })
630}
631
632/// Read one half of a pack, saying which path failed and how.
633///
634/// Checked rather than assumed: a resolved path can still be a directory, a
635/// symlink to nothing, unreadable, or empty, and each of those produces a
636/// different unhelpful error further down if it is not caught here.
637fn read_pack_file(path: &Path) -> Result<String, PackError> {
638    let metadata = std::fs::metadata(path).map_err(|e| PackError::Unreadable {
639        path: path.to_path_buf(),
640        detail: e.to_string(),
641    })?;
642    if !metadata.is_file() {
643        return Err(PackError::Unreadable {
644            path: path.to_path_buf(),
645            detail: "not a regular file".to_string(),
646        });
647    }
648    if metadata.len() == 0 {
649        return Err(PackError::Unreadable {
650            path: path.to_path_buf(),
651            detail: "the file is empty".to_string(),
652        });
653    }
654    std::fs::read_to_string(path).map_err(|e| PackError::Unreadable {
655        path: path.to_path_buf(),
656        detail: if e.kind() == std::io::ErrorKind::InvalidData {
657            "not valid UTF-8; the pack may use a legacy encoding this build cannot read".to_string()
658        } else {
659            e.to_string()
660        },
661    })
662}
663
664#[cfg(test)]
665mod tests {
666
667    #[test]
668    fn the_fingerprint_changes_when_a_pack_appears() {
669        // Installing a dictionary changes neither the document nor the
670        // config, so this is the only thing that can tell the check cache
671        // that a language has become readable.
672        let dir = std::env::temp_dir().join(format!("lc_packfp_{}", std::process::id()));
673        std::fs::create_dir_all(&dir).unwrap();
674        let registry = PackRegistry::new().with_only_search_paths(vec![dir.clone()]);
675        let asked = vec!["he".to_string()];
676
677        let before = registry.fingerprint(&asked);
678        std::fs::write(dir.join("he_IL.aff"), "SET UTF-8\n").unwrap();
679        std::fs::write(dir.join("he_IL.dic"), "1\nword\n").unwrap();
680        let after = registry.fingerprint(&asked);
681
682        assert_ne!(before, after, "a newly installed pack went unnoticed");
683
684        std::fs::remove_dir_all(&dir).ok();
685    }
686
687    #[test]
688    fn the_fingerprint_is_stable_while_nothing_changes() {
689        // Otherwise every check would miss the cache and the whole stored
690        // result would be pointless.
691        let dir = std::env::temp_dir().join(format!("lc_packfp_stable_{}", std::process::id()));
692        std::fs::create_dir_all(&dir).unwrap();
693        std::fs::write(dir.join("he_IL.aff"), "SET UTF-8\n").unwrap();
694        std::fs::write(dir.join("he_IL.dic"), "1\nword\n").unwrap();
695        let registry = PackRegistry::new().with_only_search_paths(vec![dir.clone()]);
696        let asked = vec!["he".to_string()];
697
698        assert_eq!(registry.fingerprint(&asked), registry.fingerprint(&asked));
699
700        std::fs::remove_dir_all(&dir).ok();
701    }
702
703    #[test]
704    fn replacing_a_pack_in_place_counts_as_a_change() {
705        // The path is the same, so the path alone would say nothing changed.
706        let dir = std::env::temp_dir().join(format!("lc_packfp_replace_{}", std::process::id()));
707        std::fs::create_dir_all(&dir).unwrap();
708        std::fs::write(dir.join("he_IL.aff"), "SET UTF-8\n").unwrap();
709        std::fs::write(dir.join("he_IL.dic"), "1\nword\n").unwrap();
710        let registry = PackRegistry::new().with_only_search_paths(vec![dir.clone()]);
711        let asked = vec!["he".to_string()];
712
713        let before = registry.fingerprint(&asked);
714        std::fs::write(dir.join("he_IL.dic"), "2\nword\nanother\n").unwrap();
715        assert_ne!(before, registry.fingerprint(&asked));
716
717        std::fs::remove_dir_all(&dir).ok();
718    }
719    use super::*;
720
721    /// A directory holding the named `.aff`/`.dic` stems, plus any lone files.
722    fn pack_dir(stems: &[&str], lone: &[&str]) -> tempfile::TempDir {
723        let dir = tempfile::tempdir().expect("temp dir");
724        for stem in stems {
725            std::fs::write(dir.path().join(format!("{stem}.aff")), "SET UTF-8\n").unwrap();
726            std::fs::write(dir.path().join(format!("{stem}.dic")), "1\nword\n").unwrap();
727        }
728        for name in lone {
729            std::fs::write(dir.path().join(name), "").unwrap();
730        }
731        dir
732    }
733
734    fn registry(dir: &tempfile::TempDir) -> PackRegistry {
735        PackRegistry::new().with_only_search_paths(vec![dir.path().to_path_buf()])
736    }
737
738    #[test]
739    fn an_exact_tag_wins() {
740        let dir = pack_dir(&["en_GB", "en_US"], &[]);
741        assert_eq!(registry(&dir).resolve("en-GB").unwrap().stem, "en_GB");
742    }
743
744    #[test]
745    fn a_bare_tag_finds_the_regional_pack_it_is_shipped_as() {
746        // Hspell ships Hebrew as he_IL; a document that says `lang: "he"` has
747        // to find it or the language is unsupported for no good reason.
748        let dir = pack_dir(&["he_IL"], &[]);
749        let pack = registry(&dir).resolve("he").unwrap();
750        assert_eq!(pack.stem, "he_IL");
751        assert_eq!(pack.language, "he");
752    }
753
754    #[test]
755    fn a_bare_stem_is_found_for_a_bare_tag() {
756        let dir = pack_dir(&["la"], &[]);
757        assert_eq!(registry(&dir).resolve("la").unwrap().stem, "la");
758    }
759
760    #[test]
761    fn a_bare_stem_beats_a_regional_one_for_a_bare_tag() {
762        let dir = pack_dir(&["la", "la_LA"], &[]);
763        assert_eq!(registry(&dir).resolve("la").unwrap().stem, "la");
764    }
765
766    #[test]
767    fn the_choice_among_regional_packs_is_the_same_every_run() {
768        // Directory order is not stable, and a checker that picks en_AU on one
769        // machine and en_ZA on another is not reproducible.
770        let dir = pack_dir(&["en_ZA", "en_AU", "en_CA"], &[]);
771        for _ in 0..8 {
772            assert_eq!(registry(&dir).resolve("en").unwrap().stem, "en_AU");
773        }
774    }
775
776    #[test]
777    fn a_language_with_no_pack_says_where_it_looked() {
778        let dir = pack_dir(&["en_GB"], &[]);
779        let err = registry(&dir).resolve("he").unwrap_err();
780        assert!(err.is_installable(), "a missing pack is installable");
781        let message = err.to_string();
782        assert!(message.contains("\"he\""), "{message}");
783        assert!(
784            message.contains(&dir.path().display().to_string()),
785            "{message}"
786        );
787    }
788
789    #[test]
790    fn half_a_pack_is_not_a_missing_one() {
791        // An .aff with no .dic is a broken install, not an absent one, and
792        // offering to download over it would hide the real problem.
793        let dir = pack_dir(&[], &["he_IL.aff"]);
794        let err = registry(&dir).resolve("he").unwrap_err();
795        assert!(matches!(err, PackError::Incomplete { .. }), "{err}");
796        assert!(!err.is_installable());
797        assert!(err.to_string().contains("he_IL.dic"), "{err}");
798    }
799
800    #[test]
801    fn an_override_beats_every_search_path() {
802        let installed = pack_dir(&["he_IL"], &[]);
803        let preferred = pack_dir(&["he_IL"], &[]);
804        let pack = registry(&installed)
805            .with_override("he", preferred.path())
806            .resolve("he")
807            .unwrap();
808        assert_eq!(pack.source, PackSource::Configured);
809        assert!(pack.aff.starts_with(preferred.path()), "{:?}", pack.aff);
810    }
811
812    #[test]
813    fn an_override_may_name_a_directory_a_stem_or_either_file() {
814        let dir = pack_dir(&["he_IL"], &[]);
815        let stem = dir.path().join("he_IL");
816        for form in [
817            dir.path().to_path_buf(),
818            stem.clone(),
819            stem.with_extension("aff"),
820            stem.with_extension("dic"),
821        ] {
822            let pack = PackRegistry::new()
823                .with_only_search_paths(Vec::new())
824                .with_override("he", &form)
825                .resolve("he")
826                .unwrap_or_else(|e| panic!("override {form:?} did not resolve: {e}"));
827            assert_eq!(pack.stem, "he_IL");
828            assert_eq!(pack.source, PackSource::Configured);
829        }
830    }
831
832    #[test]
833    fn an_override_pointing_nowhere_reports_the_path_it_was_given() {
834        let missing = PathBuf::from("/nonexistent/dictionaries/he_IL");
835        let err = PackRegistry::new()
836            .with_only_search_paths(Vec::new())
837            .with_override("he", &missing)
838            .resolve("he")
839            .unwrap_err();
840        assert!(matches!(err, PackError::Incomplete { .. }), "{err}");
841        assert!(err.to_string().contains("he_IL"), "{err}");
842    }
843
844    #[test]
845    fn an_earlier_search_path_wins() {
846        let first = pack_dir(&["he_IL"], &[]);
847        let second = pack_dir(&["he_IL"], &[]);
848        let pack = PackRegistry::new()
849            .with_only_search_paths(vec![
850                first.path().to_path_buf(),
851                second.path().to_path_buf(),
852            ])
853            .resolve("he")
854            .unwrap();
855        assert!(pack.aff.starts_with(first.path()));
856    }
857
858    #[test]
859    fn listing_installed_packs_reports_each_stem_once() {
860        let first = pack_dir(&["he_IL", "la"], &[]);
861        let second = pack_dir(&["he_IL", "en_GB"], &[]);
862        let installed = PackRegistry::new()
863            .with_only_search_paths(vec![
864                first.path().to_path_buf(),
865                second.path().to_path_buf(),
866            ])
867            .installed();
868        let stems: Vec<&str> = installed.iter().map(|p| p.stem.as_str()).collect();
869        assert_eq!(stems, vec!["en_GB", "he_IL", "la"]);
870    }
871
872    #[test]
873    fn a_missing_directory_is_skipped_rather_than_fatal() {
874        let dir = pack_dir(&["he_IL"], &[]);
875        let pack = PackRegistry::new()
876            .with_only_search_paths(vec![
877                PathBuf::from("/nonexistent/one"),
878                dir.path().to_path_buf(),
879            ])
880            .resolve("he")
881            .unwrap();
882        assert_eq!(pack.stem, "he_IL");
883    }
884
885    #[test]
886    fn tags_compare_without_case_or_separator() {
887        let dir = pack_dir(&["en_GB"], &[]);
888        for tag in ["en-GB", "en_gb", "EN-gb", "en_GB"] {
889            assert_eq!(registry(&dir).resolve(tag).unwrap().stem, "en_GB", "{tag}");
890        }
891    }
892
893    // ── validation ─────────────────────────────────────────────────────────
894
895    /// A pack whose halves are written verbatim, so a test can break one.
896    fn raw_pack(aff: &str, dic: &str) -> (tempfile::TempDir, ResolvedPack) {
897        let dir = tempfile::tempdir().unwrap();
898        std::fs::write(dir.path().join("xx.aff"), aff).unwrap();
899        std::fs::write(dir.path().join("xx.dic"), dic).unwrap();
900        let pack = ResolvedPack {
901            language: "xx".to_string(),
902            stem: "xx".to_string(),
903            aff: dir.path().join("xx.aff"),
904            dic: dir.path().join("xx.dic"),
905            source: PackSource::Managed,
906        };
907        (dir, pack)
908    }
909
910    const GOOD_AFF: &str = "SET UTF-8\n";
911    const GOOD_DIC: &str = "3\nalpha\nbeta\ngamma\n";
912
913    #[test]
914    fn a_sound_pack_validates_without_warnings() {
915        let (_dir, pack) = raw_pack(GOOD_AFF, GOOD_DIC);
916        let report = validate(&pack).expect("should validate");
917        assert_eq!(report.entries, 3);
918        assert_eq!(
919            report.warnings,
920            Vec::new(),
921            "a sound pack has nothing to report"
922        );
923    }
924
925    #[test]
926    fn a_dic_without_its_entry_count_is_malformed() {
927        // Not a dictionary, or one that lost its head to a bad download.
928        let (_dir, pack) = raw_pack(GOOD_AFF, "alpha\nbeta\n");
929        let err = validate(&pack).unwrap_err();
930        assert!(matches!(err, PackError::Malformed { .. }), "{err}");
931        assert!(err.to_string().contains("entry count"), "{err}");
932    }
933
934    #[test]
935    fn an_affix_file_the_parser_rejects_names_the_file() {
936        // The real defect: the 2013 Latin pack says SFK where SFX belongs, so
937        // its header promises 129 rows and the parser finds 2. Hunspell skips
938        // the unknown line; Nuspell and this do not.
939        let (_dir, pack) = raw_pack("SET UTF-8\nSFX k Y 129\nSFK k idis idos idis\n", GOOD_DIC);
940        let err = validate(&pack).unwrap_err();
941        assert!(matches!(err, PackError::Malformed { .. }), "{err}");
942        assert!(err.to_string().contains("xx.aff"), "{err}");
943    }
944
945    #[test]
946    fn a_truncated_dic_is_flagged_without_being_rejected() {
947        // Still usable, and the user should know a third of the words are gone.
948        let mut dic = String::from("300\n");
949        for i in 0..100 {
950            use std::fmt::Write as _;
951            let _ = writeln!(dic, "word{i}a");
952        }
953        let (_dir, pack) = raw_pack(GOOD_AFF, &dic);
954        let report = validate(&pack).expect("a short file is still a usable one");
955        assert_eq!(report.entries, 100);
956        assert_eq!(report.warnings.len(), 1, "{:?}", report.warnings);
957        assert!(
958            report.warnings[0].detail.contains("truncated"),
959            "{:?}",
960            report.warnings
961        );
962    }
963
964    #[test]
965    fn a_small_count_disagreement_is_not_worth_mentioning() {
966        // Every real dictionary disagrees with its own header a little.
967        let mut dic = String::from("100\n");
968        for i in 0..99 {
969            use std::fmt::Write as _;
970            let _ = writeln!(dic, "word{i}a");
971        }
972        let (_dir, pack) = raw_pack(GOOD_AFF, &dic);
973        assert_eq!(validate(&pack).unwrap().warnings, Vec::new());
974    }
975
976    #[test]
977    fn an_affix_file_with_no_declared_encoding_is_flagged() {
978        let (_dir, pack) = raw_pack("# no SET line here\n", GOOD_DIC);
979        let report = validate(&pack).expect("still usable");
980        assert!(
981            report
982                .warnings
983                .iter()
984                .any(|w| w.detail.contains("encoding")),
985            "{:?}",
986            report.warnings
987        );
988    }
989
990    #[test]
991    fn an_empty_file_is_reported_as_such() {
992        let (_dir, pack) = raw_pack("", GOOD_DIC);
993        let err = validate(&pack).unwrap_err();
994        assert!(matches!(err, PackError::Unreadable { .. }), "{err}");
995        assert!(err.to_string().contains("empty"), "{err}");
996    }
997
998    #[test]
999    fn a_directory_where_a_file_belongs_is_reported_as_such() {
1000        let dir = tempfile::tempdir().unwrap();
1001        std::fs::create_dir(dir.path().join("xx.aff")).unwrap();
1002        std::fs::write(dir.path().join("xx.dic"), GOOD_DIC).unwrap();
1003        let pack = ResolvedPack {
1004            language: "xx".to_string(),
1005            stem: "xx".to_string(),
1006            aff: dir.path().join("xx.aff"),
1007            dic: dir.path().join("xx.dic"),
1008            source: PackSource::Managed,
1009        };
1010        let err = validate(&pack).unwrap_err();
1011        assert!(err.to_string().contains("not a regular file"), "{err}");
1012    }
1013
1014    #[test]
1015    fn a_missing_file_is_reported_with_its_path() {
1016        let dir = tempfile::tempdir().unwrap();
1017        let pack = ResolvedPack {
1018            language: "xx".to_string(),
1019            stem: "xx".to_string(),
1020            aff: dir.path().join("gone.aff"),
1021            dic: dir.path().join("gone.dic"),
1022            source: PackSource::Managed,
1023        };
1024        let err = validate(&pack).unwrap_err();
1025        assert!(matches!(err, PackError::Unreadable { .. }), "{err}");
1026        assert!(err.to_string().contains("gone.aff"), "{err}");
1027    }
1028
1029    #[test]
1030    fn a_non_utf8_file_says_so_rather_than_failing_obscurely() {
1031        let dir = tempfile::tempdir().unwrap();
1032        std::fs::write(
1033            dir.path().join("xx.aff"),
1034            [0x53, 0x45, 0x54, 0x20, 0xff, 0xfe],
1035        )
1036        .unwrap();
1037        std::fs::write(dir.path().join("xx.dic"), GOOD_DIC).unwrap();
1038        let pack = ResolvedPack {
1039            language: "xx".to_string(),
1040            stem: "xx".to_string(),
1041            aff: dir.path().join("xx.aff"),
1042            dic: dir.path().join("xx.dic"),
1043            source: PackSource::Managed,
1044        };
1045        let err = validate(&pack).unwrap_err();
1046        assert!(err.to_string().contains("UTF-8"), "{err}");
1047    }
1048
1049    #[test]
1050    fn a_dictionary_that_rejects_its_own_entries_is_malformed() {
1051        // Structure intact, parser happy, and the affix rules do not match the
1052        // word list -- which no amount of shape checking would have caught.
1053        let aff = "SET UTF-8\nFORBIDDENWORD X\n";
1054        let dic = "3\nalpha/X\nbeta/X\ngamma/X\n";
1055        let (_dir, pack) = raw_pack(aff, dic);
1056        let err = validate(&pack).unwrap_err();
1057        assert!(matches!(err, PackError::Malformed { .. }), "{err}");
1058        assert!(err.to_string().contains("its own entries"), "{err}");
1059    }
1060
1061    #[test]
1062    fn a_byte_order_mark_does_not_hide_the_entry_count() {
1063        // The Latin pack ships one; without stripping it the count fails to
1064        // parse and a perfectly good dictionary reads as malformed.
1065        let (_dir, pack) = raw_pack(GOOD_AFF, "\u{feff}3\nalpha\nbeta\ngamma\n");
1066        assert_eq!(validate(&pack).expect("BOM is not corruption").entries, 3);
1067    }
1068}