Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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

Expand Down
66 changes: 48 additions & 18 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,25 @@
//! assert_eq!(errors[0].text(), "beleeve");
//! ```
//!
//! ```compile_fail
//! fn needs_send<T: Send>() {}
//! needs_send::<spellkit::Checker>();
//! ```
//!
//! [`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,
}

Expand Down Expand Up @@ -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<Self, Error> {
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).
Expand All @@ -103,7 +115,10 @@ impl Checker {
/// - **Windows:** unsupported language tag → [`Error::Unavailable`]
pub fn with_locale(locale: &str) -> Result<Self, Error> {
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`.
Expand All @@ -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<Item = SpellingError> + 'a + use<'a> {
self.0.check(text).map(SpellingError)
}
Expand Down Expand Up @@ -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::<Vec<_>>();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].text(), "beleeve");
Expand All @@ -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::<Vec<_>>();
assert_eq!(errors.len(), 4);
assert_eq!(errors[0].text(), "asdf");
Expand All @@ -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");
Expand All @@ -198,24 +217,24 @@ 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);
}

#[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);
}

Expand All @@ -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());
}
}
28 changes: 28 additions & 0 deletions src/unix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,36 @@ pub struct Checker {
hunspell: *mut Hunhandle,
}

fn env_locale() -> Option<String> {
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<Self, Error> {
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)?,
})
Expand Down
9 changes: 9 additions & 0 deletions src/win.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ pub struct Checker {

impl Checker {
pub fn new() -> Result<Self, Error> {
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")
}

Expand Down
Loading