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
- Windows backend now uses the official `windows` crate instead of `winapi`
- Bump `cfg-if` to 1.x
- macOS backend now uses `objc2` / `objc2-app-kit` instead of `cocoa` / `objc`

### Fixed
- Pass null-terminated dictionary paths to Hunspell on Linux (stops spurious
`cannot open …aff` stderr noise)
Expand Down
5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ license = "MIT OR Apache-2.0"
readme = "README.md"

[target.'cfg(target_os="macos")'.dependencies]
cocoa = "0.20.0"
objc = "0.2"
objc2 = "0.6"
objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange"] }
objc2-app-kit = { version = "0.3", features = ["NSSpellChecker"] }

[target.'cfg(windows)'.dependencies.windows]
version = "0.62"
Expand Down
198 changes: 80 additions & 118 deletions src/mac.rs
Original file line number Diff line number Diff line change
@@ -1,71 +1,48 @@
use crate::Error;

use std::ops::Deref;
use std::slice;
use std::str;
use std::sync::{Mutex, OnceLock};

use cocoa::{
appkit::NSSpellChecker,
base::{id, nil, NO},
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();
CHECKER.get_or_init(|| {
Mutex::new(unsafe { NSSpellCheckerWrapper(NSSpellChecker::sharedSpellChecker(nil)) })
})
use std::ptr;
use std::sync::Mutex;

use objc2::rc::Retained;
use objc2_app_kit::NSSpellChecker;
use objc2_foundation::{NSInteger, NSNotFound, NSRange, NSString};

/// `NSSpellChecker` is not thread-safe; serialize access.
fn with_checker<R>(f: impl FnOnce(&NSSpellChecker) -> R) -> R {
static LOCK: Mutex<()> = Mutex::new(());
let _lock = LOCK.lock().unwrap();
f(&NSSpellChecker::sharedSpellChecker())
}

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

fn language_id(language: &Option<String>) -> id {
match language {
Some(tag) => ns_string(tag),
None => nil,
}
fn language_ref(language: &Option<String>) -> Option<Retained<NSString>> {
language.as_ref().map(|tag| ns_string(tag))
}

fn nsarray_to_strings(array: id, max: usize) -> Vec<String> {
if array.is_null() {
fn nsarray_to_strings(array: Option<&objc2_foundation::NSArray<NSString>>, max: usize) -> Vec<String> {
let Some(array) = array else {
return Vec::new();
};
let take = (array.count() as usize).min(max);
let mut out = Vec::with_capacity(take);
for i in 0..take {
out.push(array.objectAtIndex(i as _).to_string());
}
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
}
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.
struct NSSpellCheckerWrapper(id);

unsafe impl Send for NSSpellCheckerWrapper {}

impl Deref for NSSpellCheckerWrapper {
type Target = id;

fn deref(&self) -> &id {
&self.0
fn utf16_offset_to_utf8(s: &str, utf16_units: usize) -> usize {
let mut units = 0;
for (byte_idx, ch) in s.char_indices() {
if units >= utf16_units {
return byte_idx;
}
units += ch.len_utf16();
}
s.len()
}

#[derive(Debug)]
Expand All @@ -77,31 +54,25 @@ pub struct Checker {

impl Drop for Checker {
fn drop(&mut self) {
unsafe {
checker()
.lock()
.unwrap()
.closeSpellDocumentWithTag(self.document_tag)
};
let tag = self.document_tag;
with_checker(|c| c.closeSpellDocumentWithTag(tag));
}
}

impl Checker {
pub fn new() -> Result<Self, Error> {
Ok(Self {
document_tag: unsafe { NSSpellChecker::uniqueSpellDocumentTag(nil) },
document_tag: NSSpellChecker::uniqueSpellDocumentTag(),
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) },
document_tag: NSSpellChecker::uniqueSpellDocumentTag(),
language: Some(bcp47.to_owned()),
})
}
Expand All @@ -112,34 +83,28 @@ impl Checker {
}

let ns_word = ns_string(word);
let lang = language_id(&self.language);
let length: NSUInteger = unsafe { msg_send![ns_word, length] };
let lang = language_ref(&self.language);
let range = NSRange {
location: 0,
length,
length: word.encode_utf16().count(),
};

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)
let tag = self.document_tag;

let guesses = with_checker(|c| {
c.guessesForWordRange_inString_language_inSpellDocumentWithTag(
range,
&ns_word,
lang.as_deref(),
tag,
)
});
nsarray_to_strings(guesses.as_deref(), MAX)
}

pub fn ignore(&mut self, word: &str) {
let word = unsafe { NSString::alloc(nil).init_str(word) };
unsafe {
checker()
.lock()
.unwrap()
.ignoreWord_inSpellDocumentWithTag(word, self.document_tag)
};
let ns_word = ns_string(word);
let tag = self.document_tag;
with_checker(|c| c.ignoreWord_inSpellDocumentWithTag(&ns_word, tag));
}

pub fn check(&mut self, text: &str) -> impl Iterator<Item = SpellingError> {
Expand All @@ -148,7 +113,6 @@ impl Checker {
ns_text: ns_string(text),
ns_offset: 0,
original: text.to_owned(),
byte_cursor: 0,
language: self.language.clone(),
}
}
Expand All @@ -175,48 +139,46 @@ impl SpellingError {

struct SpellcheckIter {
document_tag: NSInteger,
ns_text: id, /* NSString */
ns_offset: NSUInteger,
ns_text: Retained<NSString>,
ns_offset: usize,
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,
lang,
NO,
self.document_tag,
)
};

if range.location == NSNotFound as NSUInteger {
let lang = language_ref(&self.language);
let tag = self.document_tag;
let ns_text = self.ns_text.clone();
let starting = self.ns_offset as NSInteger;

let range = with_checker(|c| unsafe {
c.checkSpellingOfString_startingAt_language_wrap_inSpellDocumentWithTag_wordCount(
&ns_text,
starting,
lang.as_deref(),
false,
tag,
ptr::null_mut(),
)
});

if range.length == 0 || range.location == NSNotFound as usize {
return None;
};
}

let misspelling = unsafe {
let misspelling = self.ns_text.substringWithRange(range);
let misspelling_bytes = misspelling.UTF8String() as *const u8;
str::from_utf8(slice::from_raw_parts(misspelling_bytes, misspelling.len())).unwrap()
};
let rest = &self.original[self.byte_cursor..];
let rel = rest.find(misspelling)?;
let start = self.byte_cursor + rel;
let end = start + misspelling.len();
self.byte_cursor = end;
self.ns_offset = range.location + range.length;
let utf16_start = range.location;
let utf16_end = range.location + range.length;
let start = utf16_offset_to_utf8(&self.original, utf16_start);
let end = utf16_offset_to_utf8(&self.original, utf16_end);
let misspelling = self.original[start..end].to_owned();

self.ns_offset = utf16_end;

Some(SpellingError {
text: misspelling.to_owned(),
text: misspelling,
start,
end,
})
Expand Down
Loading