craft-proof: search the app's own dictionaries folder on macOS and Linux too

Search::for_app derived the per-app folder only on Windows. macOS now
searches ~/Library/Application Support/<App>/dictionaries and Linux and
the BSDs $XDG_DATA_HOME/<app>/dictionaries (default ~/.local/share),
before the shared system locations, so an app started from Finder, the
Dock or a launcher finds a dropped-in dictionary without a setting
(storytold/wordcraft#1063).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michał Marcin BrzuchalskiandClaude Opus 5.5 committed 2026-10-12 22:20:37 +02:00
1 parent 577e816e9b
commit 978056159c
2 files changed
+49 -8

No files matched your search

+3 -2
View File
@@ -17,8 +17,9 @@ if let Some((aff, dic)) = search.find(&["pl_PL", "pl"]) {
- **Dictionaries are not bundled.** Apps read what the OS, a package manager or LibreOffice - **Dictionaries are not bundled.** Apps read what the OS, a package manager or LibreOffice
installed; without one a language simply has no spelling check. installed; without one a language simply has no spelling check.
- **Search order** (`Search`): the app's dictionaries folder setting, `<APP>_DICTIONARIES`, the - **Search order** (`Search`): the app's dictionaries folder setting, `<APP>_DICTIONARIES`, the
app's folder under the Windows application-data folders, then the usual Hunspell and app's folder under the user's application data (`%LOCALAPPDATA%\WordCraft\dictionaries`,
LibreOffice locations for the platform. Nothing on wasm. `~/Library/Application Support/WordCraft/dictionaries`, `$XDG_DATA_HOME/wordcraft/dictionaries`),
then the usual Hunspell and LibreOffice locations for the platform. Nothing on wasm.
- **Affix engine** (`AffixDict`): the Hunspell subset real dictionaries such as SJP.PL (Polish) - **Affix engine** (`AffixDict`): the Hunspell subset real dictionaries such as SJP.PL (Polish)
and VERO (Brazilian Portuguese) use: one-character or `FLAG UTF-8` flags, prefixes and and VERO (Brazilian Portuguese) use: one-character or `FLAG UTF-8` flags, prefixes and
suffixes with conditions and cross products, continuation classes, `FORBIDDENWORD`, and the suffixes with conditions and cross products, continuation classes, `FORBIDDENWORD`, and the
+46 -6
View File
@@ -6,8 +6,8 @@
//! has no spelling check (no marks, no errors), like Word without the language's proofing tools. //! has no spelling check (no marks, no errors), like Word without the language's proofing tools.
//! //!
//! Where to look is per app ([`Search`]): its own dictionaries folder (a setting), an environment //! Where to look is per app ([`Search`]): its own dictionaries folder (a setting), an environment
//! variable and a folder under the user's application data, then the usual system locations. On //! variable and a folder under the user's application data (on every desktop system), then the
//! the web there are none. //! usual system locations. On the web there are none.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -23,15 +23,21 @@ pub struct Search {
pub extra_dir: Option<PathBuf>, pub extra_dir: Option<PathBuf>,
/// An environment variable naming a folder searched next (`WORDCRAFT_DICTIONARIES`). /// An environment variable naming a folder searched next (`WORDCRAFT_DICTIONARIES`).
pub env_var: Option<String>, pub env_var: Option<String>,
/// The app's folder name under the Windows application-data folders (`WordCraft` searches /// The app's folder name under the user's application data. `WordCraft` searches:
/// `%LOCALAPPDATA%\WordCraft\dictionaries`). /// - Windows: `%LOCALAPPDATA%\WordCraft\dictionaries`, then `%APPDATA%\WordCraft\dictionaries`
/// - macOS: `~/Library/Application Support/WordCraft/dictionaries`
/// - Linux and the BSDs: `$XDG_DATA_HOME/wordcraft/dictionaries` (default
/// `~/.local/share/wordcraft/dictionaries`; the name is lower-cased there, as XDG folders are)
///
/// An app started from Finder, the Dock or a desktop launcher doesn't see shell variables such
/// as `WORDCRAFT_DICTIONARIES`, so this folder lets users add a dictionary without any setting.
pub app_dir: Option<String>, pub app_dir: Option<String>,
} }
impl Search { impl Search {
/// The search for an app called `app` (`WordCraft`): its environment variable is the /// The search for an app called `app` (`WordCraft`): its environment variable is the
/// upper-cased name with `_DICTIONARIES` (`WORDCRAFT_DICTIONARIES`) and its Windows folder the /// upper-cased name with `_DICTIONARIES` (`WORDCRAFT_DICTIONARIES`) and its folder under the
/// name itself. /// user's application data the name itself (see [`Search::app_dir`]).
pub fn for_app(app: &str) -> Search { pub fn for_app(app: &str) -> Search {
Search { extra_dir: None, env_var: Some(format!("{}_DICTIONARIES", app.to_ascii_uppercase())), app_dir: Some(app.to_string()) } Search { extra_dir: None, env_var: Some(format!("{}_DICTIONARIES", app.to_ascii_uppercase())), app_dir: Some(app.to_string()) }
} }
@@ -65,6 +71,9 @@ impl Search {
} }
} else if cfg!(target_os = "macos") { } else if cfg!(target_os = "macos") {
if let Some(h) = &home { if let Some(h) = &home {
if let Some(app) = &self.app_dir {
dirs.push(h.join("Library/Application Support").join(app).join("dictionaries"));
}
dirs.push(h.join("Library/Spelling")); dirs.push(h.join("Library/Spelling"));
} }
dirs.push(PathBuf::from("/Library/Spelling")); dirs.push(PathBuf::from("/Library/Spelling"));
@@ -72,6 +81,14 @@ impl Search {
dirs.push(PathBuf::from("/usr/local/share/hunspell")); dirs.push(PathBuf::from("/usr/local/share/hunspell"));
dirs.extend(lo_dirs(Path::new("/Applications/LibreOffice.app/Contents/Resources/extensions"))); dirs.extend(lo_dirs(Path::new("/Applications/LibreOffice.app/Contents/Resources/extensions")));
} else { } else {
// XDG_DATA_HOME must be absolute to count; otherwise the spec's default applies.
let data_home = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| home.as_ref().map(|h| h.join(".local/share")));
if let (Some(data), Some(app)) = (&data_home, &self.app_dir) {
dirs.push(data.join(app.to_lowercase()).join("dictionaries"));
}
if let Some(h) = &home { if let Some(h) = &home {
dirs.push(h.join(".local/share/hunspell")); dirs.push(h.join(".local/share/hunspell"));
} }
@@ -218,6 +235,29 @@ mod tests {
assert_eq!(s.dirs(None).first(), Some(&extra), "the setting comes first"); assert_eq!(s.dirs(None).first(), Some(&extra), "the setting comes first");
} }
/// The app's own folder under the user's application data is searched on every desktop
/// system, before the shared system locations (storytold/wordcraft#1063).
#[test]
fn the_app_folder_comes_before_the_system_locations() {
let dirs = Search::for_app("WordCraft").dirs(None);
let at = |suffix: &str| dirs.iter().position(|d| d.ends_with(suffix));
if cfg!(target_arch = "wasm32") {
return;
}
let (app, shared) = if cfg!(windows) {
("WordCraft/dictionaries", "hunspell")
} else if cfg!(target_os = "macos") {
("Application Support/WordCraft/dictionaries", "/Library/Spelling")
} else {
("wordcraft/dictionaries", "/usr/share/hunspell")
};
if std::env::var_os(if cfg!(windows) { "LOCALAPPDATA" } else { "HOME" }).is_none() {
return; // no user folder to derive it from
}
let (Some(app), Some(shared)) = (at(app), at(shared)) else { panic!("{dirs:?}") };
assert!(app < shared, "{dirs:?}");
}
#[test] #[test]
fn finds_and_loads_a_pair_in_a_directory() { fn finds_and_loads_a_pair_in_a_directory() {
let dir = std::env::temp_dir().join(format!("craft-proof-hunspell-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("craft-proof-hunspell-{}", std::process::id()));