diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4acd44ef4..0d07db07d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6495,6 +6495,11 @@ fn dictionaries_folder_option_is_saved_searched_and_reported() { ("pl_PL", POLISH_TEST_AFF, POLISH_TEST_DIC), ("pt_BR", PORTUGUESE_TEST_AFF, PORTUGUESE_TEST_DIC), ("id_ID", INDONESIAN_TEST_AFF, INDONESIAN_TEST_DIC), + // #3252: set_dictionaries_folder() now reloads Czech too (it used to leave its cache + // alone), so a run of this test overlapping czech_proofing_through_the_review_commands + // would otherwise force a reload that finds no cs_CZ here and empties it mid-test. Same + // fixture content either way keeps such a race harmless, as it already does for Polish. + ("cs_CZ", czech_test_dict::AFF, czech_test_dict::DIC), ] { std::fs::write(dir.join(format!("{name}.aff")), aff).unwrap(); std::fs::write(dir.join(format!("{name}.dic")), dic).unwrap(); diff --git a/crates/proof/src/cs/mod.rs b/crates/proof/src/cs/mod.rs index 1d4b92f63..af743406d 100644 --- a/crates/proof/src/cs/mod.rs +++ b/crates/proof/src/cs/mod.rs @@ -13,8 +13,16 @@ use crate::affix::AffixDict; pub use suggest::suggest; -/// The Czech dictionary: `None` until first use, then the installed one (or an empty one). -static DICT: RwLock>> = RwLock::new(None); +/// The Czech dictionary and whether it came from [`install_dictionary`] rather than a file on +/// disk (it then has no file of its own to report from [`installed_path`], even if a matching +/// file also happens to exist): one lock, so a reload and an install can't interleave their two +/// fields into an inconsistent combination. +static DICT: RwLock = RwLock::new(DictState { dict: None, in_memory: false }); + +struct DictState { + dict: Option>, + in_memory: bool, +} /// File names of a Czech Hunspell dictionary. const NAMES: &[&str] = &["cs_CZ", "cs-CZ", "cs"]; @@ -23,13 +31,13 @@ const NAMES: &[&str] = &["cs_CZ", "cs-CZ", "cs"]; /// [`install_dictionary`], or an empty one when none is available (Czech spelling is off /// then; see [`has_dictionary`]). pub fn dictionary() -> Arc { - if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).dict.as_ref() { return d.clone(); } let loaded = Arc::new(load_installed()); let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner()); // Another thread (or an install) may have filled it meanwhile: that one wins. - slot.get_or_insert(loaded).clone() + slot.dict.get_or_insert(loaded).clone() } fn load_installed() -> AffixDict { @@ -47,14 +55,29 @@ fn load_installed() -> AffixDict { /// UTF-8), for example one the user picked. On error the current dictionary stays. pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> { let d = AffixDict::from_hunspell(aff, dic, &[])?; - *DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d)); + let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner()); + slot.dict = Some(Arc::new(d)); + slot.in_memory = true; Ok(()) } /// Forget the loaded dictionary, so the next check looks for an installed one again (after /// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed). pub fn reload_dictionary() { - *DICT.write().unwrap_or_else(|e| e.into_inner()) = None; + let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner()); + slot.dict = None; + slot.in_memory = false; +} + +/// The `.dic` file of the installed Czech dictionary, searching the dictionaries folder and +/// the system locations now (File › Options › Proofing shows it, #3226); `None` when there is +/// none. A dictionary given with [`install_dictionary`] has no file and isn't reported, even if a +/// matching file also happens to exist. +pub fn installed_path() -> Option { + if DICT.read().unwrap_or_else(|e| e.into_inner()).in_memory { + return None; + } + crate::hunspell::find(NAMES).map(|(_, dic)| dic) } /// Is a Czech dictionary available (spelling is checked)? diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 9347fba77..823dfb1c3 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -151,6 +151,8 @@ pub fn set_dictionaries_folder(dir: Option) { it::reload_dictionary(); hr::reload_dictionary(); tr::reload_dictionary(); + ru::reload_dictionary(); + cs::reload_dictionary(); id::reload_dictionary(); DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed); } @@ -164,6 +166,8 @@ pub fn installed_dictionaries() -> Vec<(ProofLang, Option)> (ProofLang::It, it::installed_path()), (ProofLang::Hr, hr::installed_path()), (ProofLang::Tr, tr::installed_path()), + (ProofLang::Ru, ru::installed_path()), + (ProofLang::Cs, cs::installed_path()), (ProofLang::Id, id::installed_path()), ] } @@ -977,11 +981,18 @@ mod tests { ("pt_BR", pt::test_dict::AFF, pt::test_dict::DIC), ("it_IT", it::test_dict::AFF, it::test_dict::DIC), ("hr_HR", hr::test_dict::AFF, hr::test_dict::DIC), + ("ru_RU", ru::test_dict::AFF, ru::test_dict::DIC), + ("cs_CZ", cs::test_dict::AFF, cs::test_dict::DIC), ("id_ID", id::test_dict::AFF, id::test_dict::DIC), ] { std::fs::write(dir.join(format!("{name}.aff")), aff).unwrap(); std::fs::write(dir.join(format!("{name}.dic")), dic).unwrap(); } + // #3252 review: force Russian and Czech to cache whatever they find before the folder is + // set (empty, with no folder and nothing installed on CI), so the folder change below has + // a stale cache to actually invalidate, not just an untouched `None`. + ru::dictionary(); + cs::dictionary(); let before = dictionary_generation(); set_dictionaries_folder(Some(dir.clone())); assert!(dictionary_generation() > before, "cached checks are dropped"); @@ -998,7 +1009,16 @@ mod tests { (ProofLang::Hr, Some(dir.join("hr_HR.dic"))), ] ); + // #3226: Russian and Czech were missing from installed_dictionaries() entirely (no + // installed_path() in either module), so neither ever showed up in File › Options › + // Proofing, installed or not. Their languages, not their exact path, is what belongs + // here: unlike the four above, installed_path() for Russian and Czech also reflects + // whether install_dictionary() gave one directly (#3252 review), which ru's and cs's own + // test modules do, concurrently, in tests of their own — asserting an exact path here + // would race them for no reason this test needs to care about. + assert_eq!(found[5..7].iter().map(|(lang, _)| *lang).collect::>(), [ProofLang::Ru, ProofLang::Cs], "{found:?}"); assert!(is_correct_in("chrząszcz", ProofLang::Pl) && !is_correct_in("chrzonszcz", ProofLang::Pl)); + assert!(is_correct_in("дом", ProofLang::Ru) && !is_correct_in("дум", ProofLang::Ru)); } /// #1741: a list item may be a fragment, so its first word needs no capital in any language diff --git a/crates/proof/src/ru/mod.rs b/crates/proof/src/ru/mod.rs index 4515c695c..4b1ffe093 100644 --- a/crates/proof/src/ru/mod.rs +++ b/crates/proof/src/ru/mod.rs @@ -34,8 +34,16 @@ use crate::affix::AffixDict; pub use suggest::suggest; -/// The Russian dictionary: `None` until first use, then the installed one (or an empty one). -static DICT: RwLock>> = RwLock::new(None); +/// The Russian dictionary and whether it came from [`install_dictionary`] rather than a file on +/// disk (it then has no file of its own to report from [`installed_path`], even if a matching +/// file also happens to exist): one lock, so a reload and an install can't interleave their two +/// fields into an inconsistent combination. +static DICT: RwLock = RwLock::new(DictState { dict: None, in_memory: false }); + +struct DictState { + dict: Option>, + in_memory: bool, +} /// File names of a Russian Hunspell dictionary. const NAMES: &[&str] = &["ru_RU", "ru-RU", "ru"]; @@ -44,13 +52,13 @@ const NAMES: &[&str] = &["ru_RU", "ru-RU", "ru"]; /// [`install_dictionary`], or an empty one when none is available (Russian spelling is off then; /// see [`has_dictionary`]). pub fn dictionary() -> Arc { - if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).dict.as_ref() { return d.clone(); } let loaded = Arc::new(load_installed()); let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner()); // Another thread (or an install) may have filled it meanwhile: that one wins. - slot.get_or_insert(loaded).clone() + slot.dict.get_or_insert(loaded).clone() } fn load_installed() -> AffixDict { @@ -68,14 +76,29 @@ fn load_installed() -> AffixDict { /// for example one the user picked. On error the current dictionary stays. pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> { let d = AffixDict::from_hunspell(aff, dic, &[])?; - *DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d)); + let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner()); + slot.dict = Some(Arc::new(d)); + slot.in_memory = true; Ok(()) } /// Forget the loaded dictionary, so the next check looks for an installed one again (after /// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed). pub fn reload_dictionary() { - *DICT.write().unwrap_or_else(|e| e.into_inner()) = None; + let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner()); + slot.dict = None; + slot.in_memory = false; +} + +/// The `.dic` file of the installed Russian dictionary, searching the dictionaries folder and +/// the system locations now (File › Options › Proofing shows it, #3226); `None` when there is +/// none. A dictionary given with [`install_dictionary`] has no file and isn't reported, even if a +/// matching file also happens to exist. +pub fn installed_path() -> Option { + if DICT.read().unwrap_or_else(|e| e.into_inner()).in_memory { + return None; + } + crate::hunspell::find(NAMES).map(|(_, dic)| dic) } /// Is a Russian dictionary available (spelling is checked)?