diff --git a/CHANGELOG.md b/CHANGELOG.md index d5315a5..178a525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,12 +20,15 @@ Maintained fork of [euclio/spellbound](https://github.com/euclio/spellbound). - Word tokenization with offsets on Unix (alphabetic / `'` runs) - GitHub Actions CI (Linux, macOS, Windows; fmt; clippy) - README documentation for the maintained fork and Linux hunspell setup +- `Checker::with_locale` (`en_US` / `en-US` both accepted) +- `Checker::suggest` for spelling suggestions (capped at 10) ### Changed - Edition 2021; dropped `lazy_static` / `extern crate` - macOS uses `OnceLock` for the shared `NSSpellChecker` - Windows COM failures map to `Error` where creating the checker; UTF-16 indices convert to UTF-8 byte ranges +- `hunspell-sys` 0.1.3 → 0.3.1 on Linux ### Removed - Travis CI and APPVeyor configs diff --git a/Cargo.toml b/Cargo.toml index bce7e25..3f449ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ readme = "README.md" [target.'cfg(target_os="macos")'.dependencies] cocoa = "0.20.0" +objc = "0.2" [target.'cfg(windows)'.dependencies.winapi] version = "0.3" @@ -26,3 +27,6 @@ hunspell-sys = "0.3.1" [dependencies] cfg-if = "0.1.6" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] } diff --git a/README.md b/README.md index 7e7ab4b..1ccc4db 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,7 @@ Native spell checking with a small Rust API. This is a **maintained fork** of [euclio/spellbound](https://github.com/euclio/spellbound) -(last upstream commit 2020). API 0.2 returns `Result` from `Checker::new` and exposes -UTF-8 byte ranges on spelling errors. +(last upstream commit 2020). | Platform | API | | -------- | ------------------ | @@ -25,13 +24,20 @@ use spellbound::Checker; fn main() -> Result<(), spellbound::Error> { let mut checker = Checker::new()?; + // Or: Checker::with_locale("en-US")?; + for err in checker.check("I beleeve I can fly") { println!("{} @ {}..{}", err.text(), err.start(), err.end()); + for suggestion in checker.suggest(err.text()) { + println!(" → {suggestion}"); + } } Ok(()) } ``` +Checker::new() defaults to English (en_US /en_GB on Linux, en-US on Windows). Use with_locale for another language. Locales may be written as en_US or en-US. + ## Linux Needs a hunspell dictionary on disk (default search includes `/usr/share/hunspell`). Example: diff --git a/src/lib.rs b/src/lib.rs index 62ba529..01ad74a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,26 @@ impl fmt::Display for Error { impl std::error::Error for Error {} +fn normalize_locale(locale: &str) -> (String, String) { + let mut parts = locale + .trim() + .split(['_', '-']) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect::>(); + + if let Some(lang) = parts.first_mut() { + *lang = lang.to_lowercase(); + } + if let Some(region) = parts.get_mut(1) { + *region = region.to_uppercase(); + } + + let hunspell = parts.join("_"); + let bcp47 = parts.join("-"); + (hunspell, bcp47) +} + cfg_if! { if #[cfg(target_os = "macos")] { mod mac; @@ -64,6 +84,17 @@ impl Checker { Ok(Checker(imp::Checker::new()?)) } + /// Create a checker for a specific locale (`en_US` or `en-US` both work). + pub fn with_locale(locale: &str) -> Result { + let (hunspell, bcp47) = normalize_locale(locale); + Ok(Checker(imp::Checker::with_locale(&hunspell, &bcp47)?)) + } + + /// Spelling suggestions for `word` (may be empty). + pub fn suggest(&self, word: &str) -> Vec { + self.0.suggest(word) + } + /// Check a text for spelling errors. Returns an iterator over the errors present in the text. pub fn check<'a, 'b: 'a>( &'b mut self, @@ -170,4 +201,23 @@ mod tests { assert_eq!(checker.check("foobarbaz").count(), 1); } + + #[test] + fn with_locale_en_us() { + assert!(Checker::with_locale("en_US").is_ok()); + assert!(Checker::with_locale("en-US").is_ok()); + } + + #[test] + #[cfg(all(unix, not(target_os = "macos")))] + fn with_locale_unknown() { + assert!(Checker::with_locale("zz_ZZ").is_err()); + } + + #[test] + fn suggest_misspelling() { + let checker = Checker::new().unwrap(); + let suggestions = checker.suggest("beleeve"); + assert!(!suggestions.is_empty()); + } } diff --git a/src/mac.rs b/src/mac.rs index 45eafba..4143c0c 100644 --- a/src/mac.rs +++ b/src/mac.rs @@ -8,8 +8,9 @@ use std::sync::{Mutex, OnceLock}; use cocoa::{ appkit::NSSpellChecker, base::{id, nil, NO}, - foundation::{NSInteger, NSNotFound, NSString, NSUInteger}, + foundation::{NSInteger, NSNotFound, NSRange, NSString, NSUInteger}, }; +use objc::{msg_send, sel, sel_impl}; fn checker() -> &'static Mutex { static CHECKER: OnceLock> = OnceLock::new(); @@ -18,6 +19,40 @@ fn checker() -> &'static Mutex { }) } +fn ns_string(s: &str) -> id { + unsafe { NSString::alloc(nil).init_str(s) } +} + +fn language_id(language: &Option) -> id { + match language { + Some(tag) => ns_string(tag), + None => nil, + } +} + +fn nsarray_to_strings(array: id, max: usize) -> Vec { + if array.is_null() { + return Vec::new(); + } + unsafe { + let count: NSUInteger = msg_send![array, count]; + let take = (count as usize).min(max); + let mut out = Vec::with_capacity(take); + for i in 0..take { + let item: id = msg_send![array, objectAtIndex: i]; + if item.is_null() { + continue; + } + let bytes = item.UTF8String() as *const u8; + let len = item.len(); + if let Ok(s) = str::from_utf8(slice::from_raw_parts(bytes, len)) { + out.push(s.to_owned()); + } + } + out + } +} + /// `NSSpellChecker` is not thread safe. It should only be used from one thread, or it will cause /// spurious `EXC_BAD_ACCESS` errors. If access to it is synchronized, however, it should be safe /// to send across threads. @@ -36,6 +71,8 @@ impl Deref for NSSpellCheckerWrapper { #[derive(Debug)] pub struct Checker { document_tag: NSInteger, + /// BCP-47 tag (`en-US`), or `None` for the system default language. + language: Option, } impl Drop for Checker { @@ -53,8 +90,47 @@ impl Checker { pub fn new() -> Result { Ok(Self { document_tag: unsafe { NSSpellChecker::uniqueSpellDocumentTag(nil) }, + language: None, + }) + } + + pub fn with_locale(_hunspell: &str, bcp47: &str) -> Result { + // Soft validation: reject empty; unavailable languages still may “work” + // with system fallback — zz_ZZ is only strictly Err on Unix. + if bcp47.is_empty() { + return Err(Error::Unavailable); + } + Ok(Self { + document_tag: unsafe { NSSpellChecker::uniqueSpellDocumentTag(nil) }, + language: Some(bcp47.to_owned()), }) } + pub fn suggest(&self, word: &str) -> Vec { + const MAX: usize = 10; + if word.is_empty() { + return Vec::new(); + } + + let ns_word = ns_string(word); + let lang = language_id(&self.language); + let length: NSUInteger = unsafe { msg_send![ns_word, length] }; + let range = NSRange { + location: 0, + length, + }; + + let guesses: id = unsafe { + let guard = checker().lock().unwrap(); + msg_send![ + **guard, + guessesForWordRange: range + inString: ns_word + language: lang + inSpellDocumentWithTag: self.document_tag + ] + }; + nsarray_to_strings(guesses, MAX) + } pub fn ignore(&mut self, word: &str) { let word = unsafe { NSString::alloc(nil).init_str(word) }; @@ -69,10 +145,11 @@ impl Checker { pub fn check(&mut self, text: &str) -> impl Iterator { SpellcheckIter { document_tag: self.document_tag, - ns_text: unsafe { NSString::alloc(nil).init_str(text) }, + ns_text: ns_string(text), ns_offset: 0, original: text.to_owned(), byte_cursor: 0, + language: self.language.clone(), } } } @@ -102,19 +179,21 @@ struct SpellcheckIter { ns_offset: NSUInteger, original: String, byte_cursor: usize, + language: Option, } impl Iterator for SpellcheckIter { type Item = SpellingError; fn next(&mut self) -> Option { + let lang = language_id(&self.language); let (range, _) = unsafe { checker().lock().unwrap() .checkSpellingOfString_startingAt_language_wrap_inSpellDocumentWithTag_wordCount( self.ns_text, self.ns_offset as NSInteger, - nil, + lang, NO, self.document_tag, ) diff --git a/src/unix.rs b/src/unix.rs index 1849a1e..ffdfe6d 100644 --- a/src/unix.rs +++ b/src/unix.rs @@ -1,10 +1,14 @@ use crate::Error; -use std::ffi::CString; +use std::ffi::{CStr, CString}; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; +use std::ptr; -use hunspell_sys::{Hunhandle, Hunspell_add, Hunspell_create, Hunspell_destroy, Hunspell_spell}; +use hunspell_sys::{ + Hunhandle, Hunspell_add, Hunspell_create, Hunspell_destroy, Hunspell_free_list, Hunspell_spell, + Hunspell_suggest, +}; const DICT_DIRS: &[&str] = &[ "/usr/share/hunspell", @@ -15,9 +19,9 @@ const DICT_DIRS: &[&str] = &[ const DEFAULT_LOCALES: &[&str] = &["en_US", "en_GB"]; -fn find_dictionary() -> Option<(PathBuf, PathBuf)> { +fn find_dictionary(locales: &[&str]) -> Option<(PathBuf, PathBuf)> { for dir in DICT_DIRS { - for locale in DEFAULT_LOCALES { + 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() { @@ -28,6 +32,20 @@ fn find_dictionary() -> Option<(PathBuf, PathBuf)> { None } +fn open_dictionary(locales: &[&str]) -> Result<*mut Hunhandle, Error> { + let (aff, dic) = find_dictionary(locales).ok_or(Error::Unavailable)?; + let hunspell = unsafe { + Hunspell_create( + aff.as_os_str().as_bytes().as_ptr() as *const i8, + dic.as_os_str().as_bytes().as_ptr() as *const i8, + ) + }; + if hunspell.is_null() { + return Err(Error::Unavailable); + } + Ok(hunspell) +} + #[derive(Debug)] pub struct Checker { hunspell: *mut Hunhandle, @@ -35,16 +53,49 @@ pub struct Checker { impl Checker { pub fn new() -> Result { - let (aff, dic) = find_dictionary().ok_or(Error::Unavailable)?; + Ok(Checker { + hunspell: open_dictionary(DEFAULT_LOCALES)?, + }) + } - let hunspell = unsafe { - Hunspell_create( - aff.as_os_str().as_bytes().as_ptr() as *const i8, - dic.as_os_str().as_bytes().as_ptr() as *const i8, - ) + pub fn with_locale(hunspell_locale: &str, _bcp47: &str) -> Result { + Ok(Checker { + hunspell: open_dictionary(&[hunspell_locale])?, + }) + } + + pub fn suggest(&self, word: &str) -> Vec { + const MAX: usize = 10; + + let Ok(cstr) = CString::new(word) else { + return Vec::new(); }; - Ok(Checker { hunspell }) + unsafe { + let mut list: *mut *mut i8 = ptr::null_mut(); + let n = Hunspell_suggest( + self.hunspell, + &mut list, + cstr.as_bytes_with_nul().as_ptr() as *const i8, + ); + if n <= 0 || list.is_null() { + return Vec::new(); + } + + let mut out = Vec::new(); + let take = (n as usize).min(MAX); + for i in 0..take { + let p = *list.add(i); + if p.is_null() { + continue; + } + if let Ok(s) = CStr::from_ptr(p).to_str() { + out.push(s.to_owned()); + } + } + Hunspell_free_list(self.hunspell, &mut list, n); + out + } } pub fn check<'a, 'b: 'a>( diff --git a/src/win.rs b/src/win.rs index caa9ab3..5bc5cd8 100644 --- a/src/win.rs +++ b/src/win.rs @@ -9,11 +9,12 @@ use std::ptr::{self, NonNull}; use winapi::{ shared::{ + ntdef::ULONG, winerror::{SUCCEEDED, S_FALSE, S_OK}, wtypesbase::CLSCTX_INPROC_SERVER, }, um::{ - combaseapi::{CoCreateInstance, CoInitializeEx}, + combaseapi::{CoCreateInstance, CoInitializeEx, CoTaskMemFree}, objbase::COINIT_MULTITHREADED, spellcheck::{ IEnumSpellingError, ISpellChecker, ISpellCheckerFactory, SpellCheckerFactory, @@ -74,6 +75,50 @@ fn utf16_offset_to_utf8(s: &str, utf16_units: usize) -> usize { s.len() } +fn create_factory() -> Result, Error> { + let hr = unsafe { CoInitializeEx(ptr::null_mut(), COINIT_MULTITHREADED) }; + if hr != S_OK && hr != S_FALSE { + return Err(Error::Unavailable); + } + + let mut obj = ptr::null_mut(); + let hr = unsafe { + CoCreateInstance( + &SpellCheckerFactory::uuidof(), + ptr::null_mut(), + CLSCTX_INPROC_SERVER, + &ISpellCheckerFactory::uuidof(), + &mut obj, + ) + }; + if !SUCCEEDED(hr) { + return Err(Error::Unavailable); + } + Ok(ComPtr::new(obj as *mut ISpellCheckerFactory)) +} + +fn open_for_language(bcp47: &str) -> Result, Error> { + let factory = create_factory()?; + let lang = wide_string(bcp47); + let mut checker = ptr::null_mut(); + let hr = unsafe { (*factory).CreateSpellChecker(lang.as_ptr(), &mut checker) }; + if !SUCCEEDED(hr) { + return Err(Error::Unavailable); + } + Ok(ComPtr::new(checker)) +} + +unsafe fn wide_ptr_to_string(p: *mut u16) -> Option { + if p.is_null() { + return None; + } + let mut len = 0usize; + while *p.add(len) != 0 { + len += 1; + } + String::from_utf16(std::slice::from_raw_parts(p, len)).ok() +} + #[derive(Debug)] pub struct Checker { checker: ComPtr, @@ -81,36 +126,47 @@ pub struct Checker { impl Checker { pub fn new() -> Result { - let hr = unsafe { CoInitializeEx(ptr::null_mut(), COINIT_MULTITHREADED) }; - if hr != S_OK && hr != S_FALSE { - return Err(Error::Unavailable); - } + Self::with_locale("en_US", "en-US") + } - let mut obj = ptr::null_mut(); - let hr = unsafe { - CoCreateInstance( - &SpellCheckerFactory::uuidof(), - ptr::null_mut(), - CLSCTX_INPROC_SERVER, - &ISpellCheckerFactory::uuidof(), - &mut obj, - ) - }; + pub fn with_locale(_hunspell: &str, bcp47: &str) -> Result { + Ok(Checker { + checker: open_for_language(bcp47)?, + }) + } - if !SUCCEEDED(hr) { - return Err(Error::Unavailable); - } - let factory = ComPtr::new(obj as *mut ISpellCheckerFactory); + pub fn suggest(&self, word: &str) -> Vec { + const MAX: usize = 10; - let mut checker = ptr::null_mut(); - let lang = wide_string("en-US"); - let hr = unsafe { (*factory).CreateSpellChecker(lang.as_ptr(), &mut checker) }; - if !SUCCEEDED(hr) { - return Err(Error::Unavailable); + if word.is_empty() { + return Vec::new(); } - let checker = ComPtr::new(checker); - Ok(Checker { checker }) + let wide = wide_string(word); + let mut enum_str = ptr::null_mut(); + let hr = unsafe { (*self.checker).Suggest(wide.as_ptr(), &mut enum_str) }; + if !SUCCEEDED(hr) || enum_str.is_null() { + return Vec::new(); + } + let enum_str = ComPtr::new(enum_str); + + let mut out = Vec::new(); + while out.len() < MAX { + let mut item: *mut u16 = ptr::null_mut(); + let mut fetched: ULONG = 0; + let hr = unsafe { (*enum_str).Next(1, &mut item, &mut fetched) }; + if hr == S_FALSE || fetched == 0 || item.is_null() { + break; + } + if let Some(s) = unsafe { wide_ptr_to_string(item) } { + out.push(s); + } + unsafe { CoTaskMemFree(item as *mut _) }; + if !SUCCEEDED(hr) && hr != S_FALSE { + break; + } + } + out } pub fn check(&mut self, text: &str) -> impl Iterator {