1pub mod catalogue;
17pub mod install;
18
19use std::fmt;
20use std::path::{Path, PathBuf};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum PackSource {
27 Configured,
29 System,
31 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#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ResolvedPack {
48 pub language: String,
50 pub stem: String,
52 pub aff: PathBuf,
53 pub dic: PathBuf,
54 pub source: PackSource,
55}
56
57#[derive(Debug)]
64pub enum PackError {
65 NotFound {
67 language: String,
68 searched: Vec<PathBuf>,
69 },
70 Incomplete { language: String, missing: PathBuf },
72 Unreadable { path: PathBuf, detail: String },
74 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 #[must_use]
124 pub const fn is_installable(&self) -> bool {
125 matches!(self, Self::NotFound { .. })
126 }
127}
128
129#[derive(Debug, Clone, Default)]
131pub struct PackRegistry {
132 overrides: Vec<(String, PathBuf)>,
134 search_paths: Vec<PathBuf>,
136}
137
138#[must_use]
143pub fn managed_dir() -> Option<PathBuf> {
144 dirs::data_dir().map(|d| d.join("language-check").join("dictionaries"))
145}
146
147#[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 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 #[must_use]
190 pub fn new() -> Self {
191 let mut search_paths = Vec::new();
192 search_paths.extend(managed_dir());
195 search_paths.extend(system_dirs());
196 Self {
197 overrides: Vec::new(),
198 search_paths,
199 }
200 }
201
202 #[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 #[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 #[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 #[must_use]
229 pub fn search_paths(&self) -> &[PathBuf] {
230 &self.search_paths
231 }
232
233 #[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 #[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 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 #[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
384fn normalise_tag(language: &str) -> String {
386 language.replace('-', "_").to_ascii_lowercase()
387}
388
389fn 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 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
423fn 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 if let Some(hit) = stems.iter().find(|s| normalise_tag(s) == tag) {
439 return Some(hit.clone());
440 }
441 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 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
459fn 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#[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#[derive(Debug, Clone)]
504pub struct PackReport {
505 pub pack: ResolvedPack,
506 pub entries: usize,
508 pub warnings: Vec<PackWarning>,
509}
510
511const COUNT_DRIFT_PERCENT: usize = 5;
520
521const SELFTEST_SAMPLE: usize = 64;
528
529pub 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 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 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 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 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 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
632fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}