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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"))'] }
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| -------- | ------------------ |
Expand All @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();

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;
Expand All @@ -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<Self, Error> {
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<String> {
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,
Expand Down Expand Up @@ -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());
}
}
85 changes: 82 additions & 3 deletions src/mac.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSSpellCheckerWrapper> {
static CHECKER: OnceLock<Mutex<NSSpellCheckerWrapper>> = OnceLock::new();
Expand All @@ -18,6 +19,40 @@ fn checker() -> &'static Mutex<NSSpellCheckerWrapper> {
})
}

fn ns_string(s: &str) -> id {
unsafe { NSString::alloc(nil).init_str(s) }
}

fn language_id(language: &Option<String>) -> id {
match language {
Some(tag) => ns_string(tag),
None => nil,
}
}

fn nsarray_to_strings(array: id, max: usize) -> Vec<String> {
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.
Expand All @@ -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<String>,
}

impl Drop for Checker {
Expand All @@ -53,8 +90,47 @@ impl Checker {
pub fn new() -> Result<Self, Error> {
Ok(Self {
document_tag: unsafe { NSSpellChecker::uniqueSpellDocumentTag(nil) },
language: None,
})
}

pub fn with_locale(_hunspell: &str, bcp47: &str) -> Result<Self, Error> {
// 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<String> {
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) };
Expand All @@ -69,10 +145,11 @@ impl Checker {
pub fn check(&mut self, text: &str) -> impl Iterator<Item = SpellingError> {
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(),
}
}
}
Expand Down Expand Up @@ -102,19 +179,21 @@ struct SpellcheckIter {
ns_offset: NSUInteger,
original: String,
byte_cursor: usize,
language: Option<String>,
}

impl Iterator for SpellcheckIter {
type Item = SpellingError;

fn next(&mut self) -> Option<Self::Item> {
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,
)
Expand Down
73 changes: 62 additions & 11 deletions src/unix.rs
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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() {
Expand All @@ -28,23 +32,70 @@ 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,
}

impl Checker {
pub fn new() -> Result<Self, Error> {
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<Self, Error> {
Ok(Checker {
hunspell: open_dictionary(&[hunspell_locale])?,
})
}

pub fn suggest(&self, word: &str) -> Vec<String> {
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>(
Expand Down
Loading
Loading