diff --git a/CHANGELOG.md b/CHANGELOG.md index 5087acc..33daebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ This project follows [Semantic Versioning](https://semver.org/). ### Changed - Document platform defaults for `Checker::new()`, locale failure, suggestions cap, and UTF-8 error ranges in rustdoc - README now states that macOS `Checker::new()` uses the system language +- `Checker` is no longer `Send`/`Sync` +- `Checker::new()` uses the environment/user locale on Linux and Windows when possible ### Fixed - Linux `ignore` no longer panics when the word contains an interior NUL diff --git a/README.md b/README.md index b983758..1e53f9e 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ fn main() -> Result<(), spellkit::Error> { } ``` -`Checker::new()` uses a platform default: system language on macOS, `en-US` on Windows, and the first available of `en_US` / `en_GB` on Linux. Use `with_locale` for another language. +`Checker::new()` uses a platform default: system language on macOS, the user locale on Windows (falling back to `en-US`), and `LC_ALL` / `LC_MESSAGES` / `LANG` on Linux when a dictionary exists (otherwise `en_US` / `en_GB`). Use `with_locale` for another language. Unknown or unsupported locales behave differently by platform: @@ -49,7 +49,7 @@ Unknown or unsupported locales behave differently by platform: ## Threading -macOS serializes access to the shared `NSSpellChecker`. Do not assume `Checker` is `Send` / `Sync` across platforms. +`Checker` is not `Send` or `Sync`. Do not share it across threads. macOS also serializes access to the shared `NSSpellChecker`. ## Linux diff --git a/src/lib.rs b/src/lib.rs index b1ecdfa..a026c1f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,16 +21,25 @@ //! assert_eq!(errors[0].text(), "beleeve"); //! ``` //! +//! ```compile_fail +//! fn needs_send() {} +//! needs_send::(); +//! ``` +//! //! [`ISpellChecker`]: https://docs.microsoft.com/en-us/windows/desktop/api/spellcheck/nn-spellcheck-ispellchecker //! [`NSSpellChecker`]: https://developer.apple.com/documentation/appkit/nsspellchecker //! [`hunspell`]: https://hunspell.github.io/ use cfg_if::cfg_if; use std::fmt; +use std::marker::PhantomData; #[derive(Debug)] pub enum Error { - /// No usable spell checker (e.g. missing Hunspell files on Linux, COM/create - /// failure on Windows, or an empty locale on macOS). + /// Recoverable spell-checker setup failure. + /// + /// A single variant on purpose: Linux, macOS, and Windows cannot report the same + /// failure details. Missing Hunspell files, an unsupported Windows language tag, + /// and an empty macOS locale all become [`Error::Unavailable`]. Unavailable, } @@ -80,17 +89,20 @@ cfg_if! { } /// Instance of the system spell checker. +/// +/// `Checker` is not `Send` or `Sync`. Do not share it across threads. #[derive(Debug)] -pub struct Checker(imp::Checker); +pub struct Checker(imp::Checker, PhantomData<*const ()>); impl Checker { /// Create a checker with a platform default locale. /// - /// - **Linux:** first available of `en_US`, then `en_GB` under the Hunspell directories. + /// - **Linux:** `LC_ALL` / `LC_MESSAGES` / `LANG` if a Hunspell dictionary exists, + /// otherwise `en_US` / `en_GB`. /// - **macOS:** the system default language - /// - **Windows:** `en-US` + /// - **Windows:** the user locale if the OS has a checker, otherwise `en-US` pub fn new() -> Result { - Ok(Checker(imp::Checker::new()?)) + Ok(Checker(imp::Checker::new()?, PhantomData)) } /// Create a checker for a specific locale (`en_US` or `en-US` both work). @@ -103,7 +115,10 @@ impl Checker { /// - **Windows:** unsupported language tag → [`Error::Unavailable`] pub fn with_locale(locale: &str) -> Result { let (hunspell, bcp47) = normalize_locale(locale); - Ok(Checker(imp::Checker::with_locale(&hunspell, &bcp47)?)) + Ok(Checker( + imp::Checker::with_locale(&hunspell, &bcp47)?, + PhantomData, + )) } /// Spelling suggestions for `word`. @@ -113,7 +128,11 @@ impl Checker { self.0.suggest(word) } - /// Check a text for spelling errors. Returns an iterator over the errors present in the text. + /// Check `text` for spelling errors. + /// + /// Ranges are UTF-8 byte offsets. Linux tokenizes words itself (alphanumeric + /// and `'`). macOS and Windows use the OS spell-checking APIs, so word breaks + /// may differ. pub fn check<'a>(&self, text: &'a str) -> impl Iterator + 'a + use<'a> { self.0.check(text).map(SpellingError) } @@ -158,14 +177,14 @@ mod tests { #[test] fn no_errors() { let text = "I'm happy that this sentence has no errors."; - let checker = Checker::new().unwrap(); + let checker = Checker::with_locale("en_US").unwrap(); assert_eq!(checker.check(text).count(), 0); } #[test] fn single_error() { let text = "beleeve"; - let checker = Checker::new().unwrap(); + let checker = Checker::with_locale("en_US").unwrap(); let errors = checker.check(text).collect::>(); assert_eq!(errors.len(), 1); assert_eq!(errors[0].text(), "beleeve"); @@ -175,7 +194,7 @@ mod tests { #[test] fn multiple_errors() { let text = "asdf hjkl qwer uiop"; - let checker = Checker::new().unwrap(); + let checker = Checker::with_locale("en_US").unwrap(); let errors = checker.check(text).collect::>(); assert_eq!(errors.len(), 4); assert_eq!(errors[0].text(), "asdf"); @@ -187,7 +206,7 @@ mod tests { #[test] fn error_ranges() { let text = "one asdf two"; - let checker = Checker::new().unwrap(); + let checker = Checker::with_locale("en_US").unwrap(); let errors: Vec<_> = checker.check(text).collect(); assert_eq!(errors.len(), 1); assert_eq!(errors[0].text(), "asdf"); @@ -198,13 +217,13 @@ mod tests { #[test] fn empty() { - let checker = Checker::new().unwrap(); + let checker = Checker::with_locale("en_US").unwrap(); assert_eq!(checker.check("").count(), 0); } #[test] fn ignore() { - let mut checker = Checker::new().unwrap(); + let mut checker = Checker::with_locale("en_US").unwrap(); assert_eq!(checker.check("foobarbaz").count(), 1); checker.ignore("foobarbaz"); assert_eq!(checker.check("foobarbaz").count(), 0); @@ -212,10 +231,10 @@ mod tests { #[test] fn ignore_not_permanent() { - let mut checker = Checker::new().unwrap(); + let mut checker = Checker::with_locale("en_US").unwrap(); checker.ignore("foobarbaz"); drop(checker); - let checker = Checker::new().unwrap(); + let checker = Checker::with_locale("en_US").unwrap(); assert_eq!(checker.check("foobarbaz").count(), 1); } @@ -233,15 +252,26 @@ mod tests { #[test] fn suggest_misspelling() { - let checker = Checker::new().unwrap(); + let checker = Checker::with_locale("en_US").unwrap(); let suggestions = checker.suggest("beleeve"); assert!(!suggestions.is_empty()); } #[test] fn is_correct() { - let checker = Checker::new().unwrap(); + let checker = Checker::with_locale("en_US").unwrap(); assert!(checker.is_correct("believe")); assert!(!checker.is_correct("beleeve")); } + + #[test] + fn ignore_interior_nul() { + let mut checker = Checker::with_locale("en_US").unwrap(); + checker.ignore("foo\0bar"); + } + + #[test] + fn new_succeeds() { + assert!(Checker::with_locale("en_US").is_ok()); + } } diff --git a/src/unix.rs b/src/unix.rs index 95ebba9..00b34cf 100644 --- a/src/unix.rs +++ b/src/unix.rs @@ -48,8 +48,36 @@ pub struct Checker { hunspell: *mut Hunhandle, } +fn env_locale() -> Option { + for key in ["LC_ALL", "LC_MESSAGES", "LANG"] { + let Ok(val) = std::env::var(key) else { + continue; + }; + let val = val.trim(); + if val.is_empty() || val == "C" || val == "POSIX" { + continue; + } + let base = val.split('.').next()?.split('@').next()?.trim(); + if base.is_empty() { + continue; + } + return Some(base.replace('-', "_")); + } + None +} + impl Checker { pub fn new() -> Result { + if let Some(loc) = env_locale() { + if let Ok(hunspell) = open_dictionary(&[&loc]) { + return Ok(Checker { hunspell }); + } + if let Some((lang, _)) = loc.split_once('_') { + if let Ok(hunspell) = open_dictionary(&[lang]) { + return Ok(Checker { hunspell }); + } + } + } Ok(Checker { hunspell: open_dictionary(DEFAULT_LOCALES)?, }) diff --git a/src/win.rs b/src/win.rs index 7c05387..1e9ed87 100644 --- a/src/win.rs +++ b/src/win.rs @@ -53,6 +53,15 @@ pub struct Checker { impl Checker { pub fn new() -> Result { + let mut buf = [0u16; 85]; + let n = unsafe { windows::Win32::Globalization::GetUserDefaultLocaleName(&mut buf) }; + if n > 1 { + if let Ok(tag) = String::from_utf16(&buf[..n as usize - 1]) { + if let Ok(checker) = open_for_language(&tag) { + return Ok(Checker { checker }); + } + } + } Self::with_locale("en_US", "en-US") }