diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d29a10..e57d781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/Cargo.toml b/Cargo.toml index 58b1f69..fdfc6a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/mac.rs b/src/mac.rs index 4143c0c..adf03fe 100644 --- a/src/mac.rs +++ b/src/mac.rs @@ -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 { - static CHECKER: OnceLock> = 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(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::from_str(s) } -fn language_id(language: &Option) -> id { - match language { - Some(tag) => ns_string(tag), - None => nil, - } +fn language_ref(language: &Option) -> Option> { + language.as_ref().map(|tag| ns_string(tag)) } -fn nsarray_to_strings(array: id, max: usize) -> Vec { - if array.is_null() { +fn nsarray_to_strings(array: Option<&objc2_foundation::NSArray>, max: usize) -> Vec { + 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)] @@ -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 { Ok(Self { - document_tag: unsafe { NSSpellChecker::uniqueSpellDocumentTag(nil) }, + document_tag: NSSpellChecker::uniqueSpellDocumentTag(), 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) }, + document_tag: NSSpellChecker::uniqueSpellDocumentTag(), language: Some(bcp47.to_owned()), }) } @@ -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 { @@ -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(), } } @@ -175,10 +139,9 @@ impl SpellingError { struct SpellcheckIter { document_tag: NSInteger, - ns_text: id, /* NSString */ - ns_offset: NSUInteger, + ns_text: Retained, + ns_offset: usize, original: String, - byte_cursor: usize, language: Option, } @@ -186,37 +149,36 @@ 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, - 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, })