From 978056159ca77afafd0b9e95d8d8aead18bea9d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Marcin=20Brzuchalski?= Date: Mon, 12 Oct 2026 22:20:37 +0200 Subject: [PATCH] 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//dictionaries and Linux and the BSDs $XDG_DATA_HOME//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 --- crates/proof/README.md | 5 ++-- crates/proof/src/hunspell.rs | 52 +++++++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/crates/proof/README.md b/crates/proof/README.md index 6577c511..c986b352 100644 --- a/crates/proof/README.md +++ b/crates/proof/README.md @@ -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 installed; without one a language simply has no spelling check. - **Search order** (`Search`): the app's dictionaries folder setting, `_DICTIONARIES`, the - app's folder under the Windows application-data folders, then the usual Hunspell and - LibreOffice locations for the platform. Nothing on wasm. + app's folder under the user's application data (`%LOCALAPPDATA%\WordCraft\dictionaries`, + `~/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) 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 diff --git a/crates/proof/src/hunspell.rs b/crates/proof/src/hunspell.rs index 245836dc..cd5bd024 100644 --- a/crates/proof/src/hunspell.rs +++ b/crates/proof/src/hunspell.rs @@ -6,8 +6,8 @@ //! 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 -//! variable and a folder under the user's application data, then the usual system locations. On -//! the web there are none. +//! variable and a folder under the user's application data (on every desktop system), then the +//! usual system locations. On the web there are none. use std::path::{Path, PathBuf}; @@ -23,15 +23,21 @@ pub struct Search { pub extra_dir: Option, /// An environment variable naming a folder searched next (`WORDCRAFT_DICTIONARIES`). pub env_var: Option, - /// The app's folder name under the Windows application-data folders (`WordCraft` searches - /// `%LOCALAPPDATA%\WordCraft\dictionaries`). + /// The app's folder name under the user's application data. `WordCraft` searches: + /// - 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, } impl Search { /// 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 - /// name itself. + /// upper-cased name with `_DICTIONARIES` (`WORDCRAFT_DICTIONARIES`) and its folder under the + /// user's application data the name itself (see [`Search::app_dir`]). 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()) } } @@ -65,6 +71,9 @@ impl Search { } } else if cfg!(target_os = "macos") { 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(PathBuf::from("/Library/Spelling")); @@ -72,6 +81,14 @@ impl Search { dirs.push(PathBuf::from("/usr/local/share/hunspell")); dirs.extend(lo_dirs(Path::new("/Applications/LibreOffice.app/Contents/Resources/extensions"))); } 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 { 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"); } + /// 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] fn finds_and_loads_a_pair_in_a_directory() { let dir = std::env::temp_dir().join(format!("craft-proof-hunspell-{}", std::process::id()));