diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f571afa..8bcd982 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,8 @@ jobs: sudo apt-get install -y \ libhunspell-dev \ hunspell-en-us \ + hunspell-de-de \ + hunspell-fr \ libclang-dev - name: Test @@ -46,10 +48,11 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: - components: clippy + components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - name: Install hunspell run: | sudo apt-get update sudo apt-get install -y libhunspell-dev hunspell-en-us libclang-dev + - run: cargo fmt --all -- --check - run: cargo clippy --all-targets -- -D warnings \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 33daebc..7d341df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ This project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Breaking +- `Error` is now `InvalidLocale` / `UnsupportedLocale` / `DictionaryNotFound` / `InitializationFailed` (removed `Unavailable`) +- macOS `with_locale` returns `UnsupportedLocale` if the language is not installed +- Added `Checker::locale` and `Checker::available_locales` +- Removed the crate binary (`src/main.rs`); use `examples/` + ### 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 diff --git a/Cargo.toml b/Cargo.toml index 58a8498..7f52706 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spellkit" -description = "Bindings to your friendly neighborhood spellchecker." -keywords = ["spellcheck", "spellchecker", "hunspell"] +description = "Cross-platform native spell checking for Rust (NSSpellChecker, Windows Spell Checker, Hunspell)." +keywords = ["spellcheck", "spellchecker", "hunspell", "nsspellchecker"] categories = ["os", "text-processing"] documentation = "https://docs.rs/spellkit" repository = "https://github.com/rtmongold/spellkit" diff --git a/README.md b/README.md index 1e53f9e..3e3bde1 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,47 @@ -# spellkit +# Spellkit + +Cross-platform native spell checking for Rust. [![On crates.io](https://img.shields.io/crates/v/spellkit.svg)](https://crates.io/crates/spellkit) ![Downloads](https://img.shields.io/crates/d/spellkit?style=flat-square) [![CI](https://github.com/rtmongold/spellkit/actions/workflows/ci.yml/badge.svg)](https://github.com/rtmongold/spellkit/actions/workflows/ci.yml) [![Docs](https://docs.rs/spellkit/badge.svg)](https://docs.rs/spellkit) -Native spell checking with a small Rust API. +## Why Spellkit? -This project is **based on** [euclio/spellbound](https://github.com/euclio/spellbound) -(last upstream commit 2020). +Use the spell-checking facilities already on the user's system. -| Platform | API | -| -------- | ------------------ | +| Platform | Backend | +| -------- | ------- | | macOS | [`NSSpellChecker`] | -| Windows | [`ISpellChecker`] | -| *nix | [`hunspell`] | +| Windows | [`ISpellChecker`] (Windows Spell Checker) | +| Linux / other Unix | [`Hunspell`] with system dictionaries | [`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/ +[`Hunspell`]: https://hunspell.github.io/ + +Applications should not reimplement macOS, Windows, and Hunspell separately. Spellkit is one small API over those backends. + +This project is **based on** [euclio/spellbound](https://github.com/euclio/spellbound) (last upstream commit 2020). + +## What Spellkit is not + +Spellkit does **not** bundle dictionaries or implement its own spelling algorithm. It wraps the platform backend and uses system / installed dictionaries. Behavior can differ across operating systems where the APIs differ. -## Example +That is the distinction from crates that ship an engine and word lists (for example Spellbook). + +## Quick start + + cargo add spellkit ```rust use spellkit::Checker; fn main() -> Result<(), spellkit::Error> { let checker = Checker::new()?; - // Or: Checker::with_locale("en-US")?; - for err in checker.check("I beleeve I can fly") { + for err in checker.check("I havv a spelling error.") { println!("{} @ {}..{}", err.text(), err.start(), err.end()); for suggestion in checker.suggest(err.text()) { println!(" → {suggestion}"); @@ -39,30 +51,97 @@ fn main() -> Result<(), spellkit::Error> { } ``` -`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. +`Checker::locale()` is the language this instance is using. `Checker::available_locales()` lists what the OS can check. -Unknown or unsupported locales behave differently by platform: +```rust +use spellkit::Checker; -- **Linux:** missing dictionary / unknown locale → `Error::Unavailable` -- **macOS:** empty locale → `Error::Unavailable`; unknown tags may still create a checker (system fallback) -- **Windows:** unsupported language tag → `Error::Unavailable` +fn main() -> Result<(), spellkit::Error> { + println!("available: {:?}", Checker::available_locales()); + let checker = Checker::new()?; + println!("using: {}", checker.locale()); + Ok(()) +} +``` -## Threading +## Features -`Checker` is not `Send` or `Sync`. Do not share it across threads. macOS also serializes access to the shared `NSSpellChecker`. +- Cross-platform: macOS, Windows, Linux +- System dictionaries (no files shipped in the crate) +- Suggestions (up to 10) +- Locale via `with_locale` (`en_US` and `en-US` both work) +- Temporary ignored words (`ignore` is per checker, not global) +- UTF-8 byte ranges (`start` / `end` / `range`) +- Small API + +## Platform support + +| | Linux | macOS | Windows | +| --- | --- | --- | --- | +| Backend | Hunspell | NSSpellChecker | ISpellChecker | +| `Checker::new()` | `LC_ALL` / `LC_MESSAGES` / `LANG` if a dict exists, else `en_US` / `en_GB` | system language | user locale, else `en-US` | +| Unknown `with_locale` | `Error::DictionaryNotFound` (paths searched) | `Error::UnsupportedLocale` | `Error::UnsupportedLocale` | +| Empty locale | `Error::InvalidLocale` | `Error::InvalidLocale` | `Error::InvalidLocale` | +| Suggestions | yes | yes | yes | +| `ignore` | yes (this handle only) | yes (this document tag only) | yes (this checker only) | +| `available_locales` | `*.dic` stems on disk (`DICPATH` then system dirs) | `availableLanguages` | `SupportedLanguages` | +| `Send` / `Sync` | no | no | no | + +Linux also honors `DICPATH` (colon-separated directories) before `/usr/share/hunspell` and the other built-in paths. -## Linux +Word breaks are **not** identical: Linux tokenizes alphanumeric / `'` runs; macOS and Windows use the OS checker. -Needs a hunspell dictionary on disk (default search includes `/usr/share/hunspell`). Example: +## How it works + +`Checker` is a thin wrapper. On each OS it calls the native API, then converts misspelling ranges to UTF-8 byte offsets into the original `&str`. + +## Spellkit vs other approaches + +**Why not Spellbook?** Use Spellbook when you want a portable engine and bundled (or app-shipped) dictionaries. Use Spellkit when you want the OS dictionaries, native suggestions, and minimal integration. + +**Why not Hunspell directly?** You would own dictionary discovery, FFI, and a second implementation for macOS and Windows. Spellkit is that integration. + +**Why not ispell?** You would own an external process, its lifetime, and the command protocol. Spellkit stays in-process. + +## Errors + +- empty locale → `Error::InvalidLocale` +- Linux missing `.aff`/`.dic` → `Error::DictionaryNotFound` (includes search paths) +- macOS / Windows language not installed → `Error::UnsupportedLocale` +- backend failed to start (null Hunspell handle, COM factory, empty macOS language) → `Error::InitializationFailed` + +## Linux packages - Arch: `pacman -S hunspell hunspell-en_us` - Debian/Ubuntu: `apt install libhunspell-dev hunspell-en-us` +- Extra languages used in CI: `hunspell-de-de`, `hunspell-fr` + +Without a dictionary, `Checker::new()` returns `Error::DictionaryNotFound`. + +## Examples -Without a dictionary, `Checker::new()` returns `Error::Unavailable`. + cargo run --example check -- "I havv a spelling error." + cargo run --example suggestions -- "I beleeve I can fly" + cargo run --example locale + cargo run --example highlight + +## Threading + +`Checker` is not `Send` or `Sync`. Do not share it across threads. macOS also serializes access to the shared `NSSpellChecker`. + +## Documentation + +- [docs.rs/spellkit](https://docs.rs/spellkit) +- [CHANGELOG.md](CHANGELOG.md) + +## Contributing + +Issues and PRs: [github.com/rtmongold/spellkit](https://github.com/rtmongold/spellkit) ## License + MIT OR Apache-2.0 ## Credits -Originally by [Andy Russell](https://github.com/euclio). Maintained as `spellkit` by Robert Mongold. +Originally by [Andy Russell](https://github.com/euclio). Maintained as `spellkit` by Robert Mongold. \ No newline at end of file diff --git a/examples/check.rs b/examples/check.rs new file mode 100644 index 0000000..5ae2c97 --- /dev/null +++ b/examples/check.rs @@ -0,0 +1,11 @@ +use spellkit::Checker; +use std::env; + +fn main() -> Result<(), spellkit::Error> { + let text = env::args().skip(1).collect::>().join(" "); + let checker = Checker::new()?; + for error in checker.check(&text) { + println!("{}", error.text()); + } + Ok(()) +} diff --git a/examples/highlight.rs b/examples/highlight.rs new file mode 100644 index 0000000..4bdbc04 --- /dev/null +++ b/examples/highlight.rs @@ -0,0 +1,12 @@ +use spellkit::Checker; + +fn main() -> Result<(), spellkit::Error> { + let text = "I beleeve I can fly"; + let checker = Checker::new()?; + for err in checker.check(text) { + let range = err.range(); + println!("{} @ {range:?}", err.text()); + println!(" slice: {}", &text[range]); + } + Ok(()) +} diff --git a/examples/locale.rs b/examples/locale.rs new file mode 100644 index 0000000..4bcf17e --- /dev/null +++ b/examples/locale.rs @@ -0,0 +1,6 @@ +fn main() -> Result<(), spellkit::Error> { + println!("available: {:?}", spellkit::Checker::available_locales()); + let c = spellkit::Checker::new()?; + println!("locale: {}", c.locale()); + Ok(()) +} diff --git a/examples/suggestions.rs b/examples/suggestions.rs new file mode 100644 index 0000000..76baf91 --- /dev/null +++ b/examples/suggestions.rs @@ -0,0 +1,14 @@ +use spellkit::Checker; +use std::env; + +fn main() -> Result<(), spellkit::Error> { + let text = env::args().skip(1).collect::>().join(" "); + let checker = Checker::new()?; + for error in checker.check(&text) { + println!("{}", error.text()); + for s in checker.suggest(error.text()) { + println!(" {s}"); + } + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index a026c1f..a03cb21 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,7 @@ //! ``` //! use spellkit::Checker; //! -//! let mut checker = Checker::new().unwrap(); +//! let checker = Checker::new().unwrap(); //! //! let errors: Vec<_> = checker.check("I beleeve I can fly").collect(); //! @@ -32,21 +32,38 @@ use cfg_if::cfg_if; use std::fmt; use std::marker::PhantomData; +use std::ops::Range; +use std::path::PathBuf; -#[derive(Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum Error { - /// 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, + InvalidLocale, + UnsupportedLocale { + locale: String, + }, + DictionaryNotFound { + locale: String, + searched: Vec, + }, + InitializationFailed { + locale: Option, + message: String, + }, } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Error::Unavailable => write!(f, "spell checker unavailable"), + Error::InvalidLocale => write!(f, "invalid locale"), + Error::UnsupportedLocale { locale } => write!(f, "unsupported locale: {locale}"), + Error::DictionaryNotFound { locale, searched } => write!( + f, + "dictionary not found for locale: {locale}, searched: {searched:?}" + ), + Error::InitializationFailed { locale, message } => write!( + f, + "initialization failed for locale: {locale:?}, message: {message}" + ), } } } @@ -107,14 +124,17 @@ impl Checker { /// Create a checker for a specific locale (`en_US` or `en-US` both work). /// - /// Unknown or unsupported locales behave differently by platform: - /// - /// - **Linux:** missing dictionary → [`Error::Unavailable`] - /// - **macOS:** empty locale → [`Error::Unavailable`]; unknown tags may still - /// succeed (system fallback) - /// - **Windows:** unsupported language tag → [`Error::Unavailable`] + /// - empty string - [`Error::InvalidLocale`] + /// - **Linux:** missing `.aff` / `.dic` → [`Error::DictionaryNotFound`] + /// - **macOS / Windows:** language not installed → [`Error::UnsupportedLocale`] pub fn with_locale(locale: &str) -> Result { + if locale.trim().is_empty() { + return Err(Error::InvalidLocale); + } let (hunspell, bcp47) = normalize_locale(locale); + if hunspell.is_empty() { + return Err(Error::InvalidLocale); + } Ok(Checker( imp::Checker::with_locale(&hunspell, &bcp47)?, PhantomData, @@ -148,6 +168,14 @@ impl Checker { pub fn ignore(&mut self, word: &str) { self.0.ignore(word) } + + pub fn locale(&self) -> &str { + self.0.locale() + } + + pub fn available_locales() -> Vec { + imp::Checker::available_locales() + } } /// A spelling error. @@ -168,11 +196,21 @@ impl SpellingError { pub fn end(&self) -> usize { self.0.end() } + + pub fn range(&self) -> Range { + self.start()..self.end() + } +} + +impl fmt::Display for SpellingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} @ {}..{}", self.text(), self.start(), self.end()) + } } #[cfg(test)] mod tests { - use super::Checker; + use super::{Checker, Error}; #[test] fn no_errors() { @@ -244,10 +282,102 @@ mod tests { assert!(Checker::with_locale("en-US").is_ok()); } + #[test] + fn with_locale_empty() { + assert!(matches!( + Checker::with_locale(""), + Err(Error::InvalidLocale) + )); + } + #[test] #[cfg(all(unix, not(target_os = "macos")))] fn with_locale_unknown() { - assert!(Checker::with_locale("zz_ZZ").is_err()); + match Checker::with_locale("zz_ZZ") { + Err(Error::DictionaryNotFound { locale, searched }) => { + assert!(locale.contains("zz")); + assert!(!searched.is_empty()); + } + other => panic!("expected DictionaryNotFound, got {other:?}"), + } + } + + #[test] + #[cfg(any(windows, target_os = "macos"))] + fn with_locale_unknown() { + match Checker::with_locale("zz_ZZ") { + Err(Error::UnsupportedLocale { locale }) => { + assert!(locale.to_lowercase().contains("zz")); + } + other => panic!("expected UnsupportedLocale, got {other:?}"), + } + } + + #[cfg(all(unix, not(target_os = "macos")))] + fn unix_locale_or_skip(locales: &[&str]) -> Option { + for tag in locales { + match Checker::with_locale(tag) { + Ok(c) => return Some(c), + Err(Error::DictionaryNotFound { .. }) => continue, + Err(e) => panic!("{e}"), + } + } + if std::env::var_os("CI").is_some() { + panic!("missing Hunspell dicts for {locales:?} (CI must install them)"); + } + None + } + + #[test] + #[cfg(all(unix, not(target_os = "macos")))] + fn hunspell_de_de() { + let Some(checker) = unix_locale_or_skip(&["de_DE", "de"]) else { + return; + }; + assert!(checker.is_correct("Haus")); + assert!(!checker.is_correct("Hauzz")); + assert!(!checker.suggest("Hauzz").is_empty()); + } + + #[test] + #[cfg(all(unix, not(target_os = "macos")))] + fn hunspell_fr() { + let Some(checker) = unix_locale_or_skip(&["fr_FR", "fr"]) else { + return; + }; + assert!(checker.is_correct("bonjour")); + assert!(!checker.is_correct("bonjoour")); + assert!(!checker.suggest("bonjoour").is_empty()); + } + + #[test] + fn utf8_range() { + let text = "café beleeve"; + let checker = Checker::with_locale("en_US").unwrap(); + let errors: Vec<_> = checker.check(text).collect(); + let e = errors + .iter() + .find(|e| e.text() == "beleeve") + .unwrap_or_else(|| { + panic!( + "expected beleeve, got {:?}", + errors.iter().map(|e| e.text()).collect::>() + ) + }); + assert_eq!(&text[e.start()..e.end()], "beleeve"); + assert_eq!(e.range(), e.start()..e.end()); + assert!(e.start() > 0); + } + + #[test] + fn locale_en() { + let checker = Checker::with_locale("en_US").unwrap(); + assert!(checker.locale().to_lowercase().contains("en")); + } + + #[test] + fn available_locales_nonempty() { + assert!(!Checker::available_locales().is_empty()); } #[test] diff --git a/src/mac.rs b/src/mac.rs index fb43b37..701ef5b 100644 --- a/src/mac.rs +++ b/src/mac.rs @@ -18,8 +18,19 @@ fn ns_string(s: &str) -> Retained { NSString::from_str(s) } -fn language_ref(language: &Option) -> Option> { - language.as_ref().map(|tag| ns_string(tag)) +fn language_ref(language: &str) -> Retained { + ns_string(language) +} + +fn mac_locale_supported(requested_bcp47: &str, available: &str) -> bool { + let want = requested_bcp47.replace('_', "-"); + let have = available.replace('_', "-"); + if want.eq_ignore_ascii_case(&have) { + return true; + } + let want_lang = want.split('-').next().unwrap_or(""); + let have_parts: Vec<_> = have.split('-').collect(); + have_parts.len() == 1 && have_parts[0].eq_ignore_ascii_case(want_lang) } fn nsarray_to_strings( @@ -51,8 +62,7 @@ fn utf16_offset_to_utf8(s: &str, utf16_units: usize) -> usize { #[derive(Debug)] pub struct Checker { document_tag: NSInteger, - /// BCP-47 tag (`en-US`), or `None` for the system default language. - language: Option, + language: String, } impl Drop for Checker { @@ -64,21 +74,45 @@ impl Drop for Checker { impl Checker { pub fn new() -> Result { + let language = with_checker(|c| c.language().to_string()); + if language.is_empty() { + return Err(Error::InitializationFailed { + locale: None, + message: "NSSpellChecker.language is empty".into(), + }); + } Ok(Self { document_tag: NSSpellChecker::uniqueSpellDocumentTag(), - language: None, + language, }) } pub fn with_locale(_hunspell: &str, bcp47: &str) -> Result { if bcp47.is_empty() { - return Err(Error::Unavailable); + return Err(Error::InvalidLocale); + } + let available = + with_checker(|c| nsarray_to_strings(Some(c.availableLanguages().as_ref()), usize::MAX)); + let ok = available.iter().any(|t| mac_locale_supported(bcp47, t)); + if !ok { + return Err(Error::UnsupportedLocale { + locale: bcp47.to_owned(), + }); } Ok(Self { document_tag: NSSpellChecker::uniqueSpellDocumentTag(), - language: Some(bcp47.to_owned()), + language: bcp47.to_owned(), }) } + + pub fn locale(&self) -> &str { + &self.language + } + + pub fn available_locales() -> Vec { + with_checker(|c| nsarray_to_strings(Some(c.availableLanguages().as_ref()), usize::MAX)) + } + pub fn suggest(&self, word: &str) -> Vec { const MAX: usize = 10; if word.is_empty() { @@ -97,7 +131,7 @@ impl Checker { c.guessesForWordRange_inString_language_inSpellDocumentWithTag( range, &ns_word, - lang.as_deref(), + Some(&*lang), tag, ) }); @@ -145,7 +179,7 @@ struct SpellcheckIter { ns_text: Retained, ns_offset: usize, original: String, - language: Option, + language: String, } impl Iterator for SpellcheckIter { @@ -161,7 +195,7 @@ impl Iterator for SpellcheckIter { c.checkSpellingOfString_startingAt_language_wrap_inSpellDocumentWithTag_wordCount( &ns_text, starting, - lang.as_deref(), + Some(&*lang), false, tag, ptr::null_mut(), diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 53c829e..0000000 --- a/src/main.rs +++ /dev/null @@ -1,13 +0,0 @@ -use std::env; - -use spellkit::Checker; - -fn main() { - let text = env::args().skip(1).collect::>().join(" "); - - let checker = Checker::new().unwrap(); - - for error in checker.check(&text) { - println!("ERROR: {}", error.text()); - } -} diff --git a/src/unix.rs b/src/unix.rs index 00b34cf..2abbb8f 100644 --- a/src/unix.rs +++ b/src/unix.rs @@ -19,33 +19,67 @@ const DICT_DIRS: &[&str] = &[ const DEFAULT_LOCALES: &[&str] = &["en_US", "en_GB"]; -fn find_dictionary(locales: &[&str]) -> Option<(PathBuf, PathBuf)> { +fn dict_search_dirs() -> Vec { + let mut dirs = Vec::new(); + if let Ok(dicpath) = std::env::var("DICPATH") { + for part in dicpath.split(':') { + if !part.is_empty() { + dirs.push(PathBuf::from(part)); + } + } + } for dir in DICT_DIRS { + dirs.push(PathBuf::from(dir)); + } + dirs +} + +fn find_dictionary(dirs: &[PathBuf], locales: &[&str]) -> Option<(PathBuf, PathBuf, String)> { + for dir in dirs { for locale in locales { let aff = Path::new(dir).join(format!("{locale}.aff")); let dic = Path::new(dir).join(format!("{locale}.dic")); if aff.is_file() && dic.is_file() { - return Some((aff, dic)); + return Some((aff, dic, (*locale).to_owned())); } } } None } -fn open_dictionary(locales: &[&str]) -> Result<*mut Hunhandle, Error> { - let (aff, dic) = find_dictionary(locales).ok_or(Error::Unavailable)?; - let aff_c = CString::new(aff.as_os_str().as_bytes()).map_err(|_| Error::Unavailable)?; - let dic_c = CString::new(dic.as_os_str().as_bytes()).map_err(|_| Error::Unavailable)?; +fn open_dictionary(locales: &[&str]) -> Result<(*mut Hunhandle, String), Error> { + let dirs = dict_search_dirs(); + let Some((aff, dic, locale)) = find_dictionary(&dirs, locales) else { + return Err(Error::DictionaryNotFound { + locale: locales.join(","), + searched: dirs, + }); + }; + let label = Some(locale.clone()); + let aff_c = + CString::new(aff.as_os_str().as_bytes()).map_err(|_| Error::InitializationFailed { + locale: label.clone(), + message: "dictionary path contains NUL".into(), + })?; + let dic_c = + CString::new(dic.as_os_str().as_bytes()).map_err(|_| Error::InitializationFailed { + locale: label.clone(), + message: "dictionary path contains NUL".into(), + })?; let hunspell = unsafe { Hunspell_create(aff_c.as_ptr(), dic_c.as_ptr()) }; if hunspell.is_null() { - return Err(Error::Unavailable); + return Err(Error::InitializationFailed { + locale: label.clone(), + message: "Hunspell_create returned null".into(), + }); } - Ok(hunspell) + Ok((hunspell, locale)) } #[derive(Debug)] pub struct Checker { hunspell: *mut Hunhandle, + locale: String, } fn env_locale() -> Option { @@ -69,24 +103,47 @@ fn env_locale() -> Option { 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 Ok((hunspell, locale)) = open_dictionary(&[&loc]) { + return Ok(Checker { hunspell, locale }); } if let Some((lang, _)) = loc.split_once('_') { - if let Ok(hunspell) = open_dictionary(&[lang]) { - return Ok(Checker { hunspell }); + if let Ok((hunspell, locale)) = open_dictionary(&[lang]) { + return Ok(Checker { hunspell, locale }); } } } - Ok(Checker { - hunspell: open_dictionary(DEFAULT_LOCALES)?, - }) + let (hunspell, locale) = open_dictionary(DEFAULT_LOCALES)?; + Ok(Checker { hunspell, locale }) } pub fn with_locale(hunspell_locale: &str, _bcp47: &str) -> Result { - Ok(Checker { - hunspell: open_dictionary(&[hunspell_locale])?, - }) + let (hunspell, locale) = open_dictionary(&[hunspell_locale])?; + Ok(Checker { hunspell, locale }) + } + + pub fn locale(&self) -> &str { + &self.locale + } + + pub fn available_locales() -> Vec { + let mut out = Vec::new(); + for dir in dict_search_dirs() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for ent in entries.flatten() { + let path = ent.path(); + if path.extension().and_then(|e| e.to_str()) != Some("dic") { + continue; + } + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + out.push(stem.to_owned()); + } + } + } + out.sort(); + out.dedup(); + out } pub fn suggest(&self, word: &str) -> Vec { diff --git a/src/win.rs b/src/win.rs index 1e9ed87..76225bf 100644 --- a/src/win.rs +++ b/src/win.rs @@ -35,20 +35,73 @@ fn utf16_offset_to_utf8(s: &str, utf16_units: usize) -> usize { } fn open_for_language(bcp47: &str) -> Result { - // S_OK / S_FALSE (already initialized) both succeed via windows::Result + if bcp47.is_empty() { + return Err(Error::InvalidLocale); + } let _ = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; let factory: ISpellCheckerFactory = - unsafe { CoCreateInstance(&SpellCheckerFactory, None, CLSCTX_INPROC_SERVER) } - .map_err(|_| Error::Unavailable)?; + unsafe { CoCreateInstance(&SpellCheckerFactory, None, CLSCTX_INPROC_SERVER) }.map_err( + |e| Error::InitializationFailed { + locale: Some(bcp47.to_owned()), + message: e.to_string(), + }, + )?; let tag = HSTRING::from(bcp47); - unsafe { factory.CreateSpellChecker(&tag) }.map_err(|_| Error::Unavailable) + let supported = + unsafe { factory.IsSupported(&tag) }.map_err(|e| Error::InitializationFailed { + locale: Some(bcp47.to_owned()), + message: e.to_string(), + })?; + if !supported.as_bool() { + return Err(Error::UnsupportedLocale { + locale: bcp47.to_owned(), + }); + } + unsafe { factory.CreateSpellChecker(&tag) }.map_err(|e| Error::InitializationFailed { + locale: Some(bcp47.to_owned()), + message: e.to_string(), + }) +} + +fn supported_language_tags() -> Vec { + let _ = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; + let factory: ISpellCheckerFactory = + match unsafe { CoCreateInstance(&SpellCheckerFactory, None, CLSCTX_INPROC_SERVER) } { + Ok(f) => f, + Err(_) => return Vec::new(), + }; + let Ok(enum_str) = (unsafe { factory.SupportedLanguages() }) else { + return Vec::new(); + }; + let mut out = Vec::new(); + loop { + let mut item = [PWSTR::null()]; + let mut fetched = 0u32; + let hr = unsafe { enum_str.Next(&mut item, Some(&mut fetched)) }; + if fetched == 0 || item[0].is_null() { + break; + } + if let Ok(s) = unsafe { item[0].to_string() } { + out.push(s); + } + unsafe { + CoTaskMemFree(Some(item[0].as_ptr() as *const _)); + } + if hr.is_err() && hr != S_FALSE { + break; + } + } + out.sort(); + out.dedup(); + out } #[derive(Debug)] pub struct Checker { checker: ISpellChecker, + locale: String, } impl Checker { @@ -58,7 +111,10 @@ impl Checker { 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 }); + return Ok(Checker { + checker, + locale: tag, + }); } } } @@ -68,9 +124,18 @@ impl Checker { pub fn with_locale(_hunspell: &str, bcp47: &str) -> Result { Ok(Checker { checker: open_for_language(bcp47)?, + locale: bcp47.to_owned(), }) } + pub fn locale(&self) -> &str { + &self.locale + } + + pub fn available_locales() -> Vec { + supported_language_tags() + } + pub fn suggest(&self, word: &str) -> Vec { const MAX: usize = 10;