use std::collections::HashSet;
use std::ffi::OsStr;
use std::fs;
use std::path::Path;
use spellbook::Dictionary;
use super::check::SpellChecker;
pub fn load(
dict_dir: &Path,
bundled_dir: Option<&Path>,
personal_path: &Path,
enabled: bool,
selected: &[String],
) -> SpellChecker {
let mut dicts = Vec::new();
if !enabled {
tracing::info!("spellcheck disabled in settings — dictionaries aren't loaded");
} else {
let mut loaded = HashSet::new();
load_dir(dict_dir, selected, &mut loaded, &mut dicts);
if let Some(bundled) = bundled_dir
&& bundled != dict_dir
{
load_dir(bundled, selected, &mut loaded, &mut dicts);
}
}
let personal = load_personal(personal_path);
SpellChecker::new(dicts, personal, Some(personal_path.to_path_buf()))
}
fn load_dir(
dir: &Path,
selected: &[String],
loaded: &mut HashSet<String>,
dicts: &mut Vec<Dictionary>,
) {
match fs::read_dir(dir) {
Ok(entries) => {
for entry in entries.flatten() {
load_entry(&entry.path(), selected, loaded, dicts);
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
tracing::info!(dir = %dir.display(), "dictionary directory is missing");
}
Err(err) => {
tracing::warn!(dir = %dir.display(), error = %err, "failed to read the dictionary directory");
}
}
}
fn load_entry(
aff: &Path,
selected: &[String],
loaded: &mut HashSet<String>,
dicts: &mut Vec<Dictionary>,
) {
if aff.extension().and_then(OsStr::to_str) != Some("aff") {
return;
}
let stem = aff.file_stem().and_then(OsStr::to_str).unwrap_or_default();
if !selected.is_empty() && !selected.iter().any(|s| s == stem) {
return;
}
if loaded.contains(stem) {
return;
}
let dic = aff.with_extension("dic");
if !dic.exists() {
return;
}
match load_pair(aff, &dic) {
Ok(dict) => {
tracing::info!(dict = %aff.display(), "dictionary loaded");
loaded.insert(stem.to_string());
dicts.push(dict);
}
Err(err) => {
tracing::warn!(dict = %aff.display(), error = %err, "dictionary skipped");
}
}
}
fn load_pair(aff: &Path, dic: &Path) -> anyhow::Result<Dictionary> {
let aff_text = fs::read_to_string(aff)?;
let dic_text = fs::read_to_string(dic)?;
Dictionary::new(&aff_text, &dic_text)
.map_err(|e| anyhow::anyhow!("dictionary parse error: {e}"))
}
pub fn load_personal(path: &Path) -> HashSet<String> {
let mut set = HashSet::new();
if let Ok(text) = fs::read_to_string(path) {
for line in text.lines() {
let word = line.trim();
if !word.is_empty() && !word.starts_with('#') {
set.insert(word.to_string());
}
}
}
set
}
pub fn append_personal(path: &Path, word: &str) -> std::io::Result<()> {
use std::io::Write;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(file, "{word}")
}
#[cfg(test)]
mod tests {
use super::*;
const AFF: &str = "SET UTF-8\n";
const DIC: &str = "3\nhello\nworld\ncat\n";
fn write(dir: &Path, name: &str, content: &str) {
fs::write(dir.join(name), content).unwrap();
}
fn load_all(dir: &Path) -> SpellChecker {
load(dir, None, &dir.join("personal.txt"), true, &[])
}
#[test]
fn loads_aff_dic_pair() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "en.aff", AFF);
write(dir.path(), "en.dic", DIC);
let checker = load_all(dir.path());
assert!(checker.is_enabled());
assert!(checker.check_word("hello"));
assert!(!checker.check_word("zxcvb"));
}
#[test]
fn missing_dir_disables_checker() {
let dir = tempfile::tempdir().unwrap();
let checker = load_all(&dir.path().join("nope"));
assert!(!checker.is_enabled());
}
#[test]
fn the_invitation_file_is_not_a_dictionary() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
crate::shared::paths::DICTIONARIES_README,
"put your dictionaries here\n",
);
assert!(!load_all(dir.path()).is_enabled());
write(dir.path(), "en.aff", AFF);
write(dir.path(), "en.dic", DIC);
let checker = load_all(dir.path());
assert!(checker.is_enabled());
assert!(checker.check_word("hello"));
}
#[test]
fn aff_without_dic_is_skipped() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "en.aff", AFF); let checker = load_all(dir.path());
assert!(!checker.is_enabled());
}
#[test]
fn disabled_skips_loading() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "en.aff", AFF);
write(dir.path(), "en.dic", DIC);
let checker = load(
dir.path(),
None,
&dir.path().join("personal.txt"),
false,
&[],
);
assert!(!checker.is_enabled());
}
#[test]
fn selection_filters_dictionaries() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "en_US.aff", AFF);
write(dir.path(), "en_US.dic", DIC);
write(dir.path(), "ru_RU.aff", AFF);
write(dir.path(), "ru_RU.dic", "1\nпривет\n");
let checker = load(
dir.path(),
None,
&dir.path().join("personal.txt"),
true,
&["en_US".to_string()],
);
assert!(checker.check_word("hello")); assert!(!checker.check_word("привет")); }
#[test]
fn bundled_dir_supplies_missing_dictionaries() {
let data = tempfile::tempdir().unwrap();
let bundled = tempfile::tempdir().unwrap();
write(bundled.path(), "en.aff", AFF);
write(bundled.path(), "en.dic", DIC);
let checker = load(
data.path(),
Some(bundled.path()),
&data.path().join("personal.txt"),
true,
&[],
);
assert!(checker.is_enabled());
assert!(checker.check_word("hello"));
}
#[test]
fn data_dir_dictionary_wins_over_bundled() {
let data = tempfile::tempdir().unwrap();
let bundled = tempfile::tempdir().unwrap();
write(data.path(), "en.aff", AFF);
write(data.path(), "en.dic", "1\nhello\n"); write(bundled.path(), "en.aff", AFF);
write(bundled.path(), "en.dic", DIC); let checker = load(
data.path(),
Some(bundled.path()),
&data.path().join("personal.txt"),
true,
&[],
);
assert!(checker.check_word("hello"));
assert!(!checker.check_word("world"));
}
#[test]
fn the_bundled_dictionaries_load_and_answer() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("dictionaries");
let personal = dir.join("does-not-exist.txt");
for (name, present, absent) in [
(
"en_US",
["color", "organize", "neighbor"],
["colour", "neighbour"],
),
(
"en_GB",
["colour", "organise", "organize"],
["color", "neighbor"],
),
(
"ru_RU",
["словарь", "проверка", "терминал"],
["словарьь", "проверкаа"],
),
] {
let checker = load(&dir, None, &personal, true, &[name.to_string()]);
for word in present {
assert!(
checker.check_word(word),
"{name}: {word:?} should be a word"
);
}
for word in absent {
assert!(
!checker.check_word(word),
"{name}: {word:?} should not be a word"
);
}
}
}
#[test]
fn the_bundled_russian_reads_a_stressed_word() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("dictionaries");
let checker = load(
&dir,
None,
&dir.join("does-not-exist.txt"),
true,
&["ru_RU".to_string()],
);
for word in [
"И\u{301}стинно",
"мо\u{301}локо",
"по-мо\u{301}ему",
"хорошо\u{301}",
] {
assert!(checker.check_word(word), "{word:?} should be a word");
}
for word in ["харашо\u{301}", "исти\u{301}ный"] {
assert!(!checker.check_word(word), "{word:?} should not be a word");
}
}
#[test]
fn personal_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("personal.txt");
fs::write(&path, "# комментарий\nмойтермин\n\n").unwrap();
let set = load_personal(&path);
assert!(set.contains("мойтермин"));
assert_eq!(set.len(), 1);
append_personal(&path, "ещёслово").unwrap();
let set2 = load_personal(&path);
assert!(set2.contains("ещёслово"));
assert!(set2.contains("мойтермин"));
}
}