Skip to content

Commit 4d8680f

Browse files
authored
Migrate macOS backend from cocoa/objc to objc2 (#10)
Replace cocoa and objc with objc2 /objc2-foundation /objc2-app-kit for NSSpellChecker, and map misspelling ranges via UTF-16 offsets.
1 parent 41dff64 commit 4d8680f

3 files changed

Lines changed: 85 additions & 120 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ This project follows [Semantic Versioning](https://semver.org/).
99
### Changed
1010
- Windows backend now uses the official `windows` crate instead of `winapi`
1111
- Bump `cfg-if` to 1.x
12+
- macOS backend now uses `objc2` / `objc2-app-kit` instead of `cocoa` / `objc`
13+
1214
### Fixed
1315
- Pass null-terminated dictionary paths to Hunspell on Linux (stops spurious
1416
`cannot open …aff` stderr noise)

Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ license = "MIT OR Apache-2.0"
1515
readme = "README.md"
1616

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

2122
[target.'cfg(windows)'.dependencies.windows]
2223
version = "0.62"

src/mac.rs

Lines changed: 80 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,48 @@
11
use crate::Error;
22

3-
use std::ops::Deref;
4-
use std::slice;
5-
use std::str;
6-
use std::sync::{Mutex, OnceLock};
7-
8-
use cocoa::{
9-
appkit::NSSpellChecker,
10-
base::{id, nil, NO},
11-
foundation::{NSInteger, NSNotFound, NSRange, NSString, NSUInteger},
12-
};
13-
use objc::{msg_send, sel, sel_impl};
14-
15-
fn checker() -> &'static Mutex<NSSpellCheckerWrapper> {
16-
static CHECKER: OnceLock<Mutex<NSSpellCheckerWrapper>> = OnceLock::new();
17-
CHECKER.get_or_init(|| {
18-
Mutex::new(unsafe { NSSpellCheckerWrapper(NSSpellChecker::sharedSpellChecker(nil)) })
19-
})
3+
use std::ptr;
4+
use std::sync::Mutex;
5+
6+
use objc2::rc::Retained;
7+
use objc2_app_kit::NSSpellChecker;
8+
use objc2_foundation::{NSInteger, NSNotFound, NSRange, NSString};
9+
10+
/// `NSSpellChecker` is not thread-safe; serialize access.
11+
fn with_checker<R>(f: impl FnOnce(&NSSpellChecker) -> R) -> R {
12+
static LOCK: Mutex<()> = Mutex::new(());
13+
let _lock = LOCK.lock().unwrap();
14+
f(&NSSpellChecker::sharedSpellChecker())
2015
}
2116

22-
fn ns_string(s: &str) -> id {
23-
unsafe { NSString::alloc(nil).init_str(s) }
17+
fn ns_string(s: &str) -> Retained<NSString> {
18+
NSString::from_str(s)
2419
}
2520

26-
fn language_id(language: &Option<String>) -> id {
27-
match language {
28-
Some(tag) => ns_string(tag),
29-
None => nil,
30-
}
21+
fn language_ref(language: &Option<String>) -> Option<Retained<NSString>> {
22+
language.as_ref().map(|tag| ns_string(tag))
3123
}
3224

33-
fn nsarray_to_strings(array: id, max: usize) -> Vec<String> {
34-
if array.is_null() {
25+
fn nsarray_to_strings(array: Option<&objc2_foundation::NSArray<NSString>>, max: usize) -> Vec<String> {
26+
let Some(array) = array else {
3527
return Vec::new();
28+
};
29+
let take = (array.count() as usize).min(max);
30+
let mut out = Vec::with_capacity(take);
31+
for i in 0..take {
32+
out.push(array.objectAtIndex(i as _).to_string());
3633
}
37-
unsafe {
38-
let count: NSUInteger = msg_send![array, count];
39-
let take = (count as usize).min(max);
40-
let mut out = Vec::with_capacity(take);
41-
for i in 0..take {
42-
let item: id = msg_send![array, objectAtIndex: i];
43-
if item.is_null() {
44-
continue;
45-
}
46-
let bytes = item.UTF8String() as *const u8;
47-
let len = item.len();
48-
if let Ok(s) = str::from_utf8(slice::from_raw_parts(bytes, len)) {
49-
out.push(s.to_owned());
50-
}
51-
}
52-
out
53-
}
34+
out
5435
}
5536

56-
/// `NSSpellChecker` is not thread safe. It should only be used from one thread, or it will cause
57-
/// spurious `EXC_BAD_ACCESS` errors. If access to it is synchronized, however, it should be safe
58-
/// to send across threads.
59-
struct NSSpellCheckerWrapper(id);
60-
61-
unsafe impl Send for NSSpellCheckerWrapper {}
62-
63-
impl Deref for NSSpellCheckerWrapper {
64-
type Target = id;
65-
66-
fn deref(&self) -> &id {
67-
&self.0
37+
fn utf16_offset_to_utf8(s: &str, utf16_units: usize) -> usize {
38+
let mut units = 0;
39+
for (byte_idx, ch) in s.char_indices() {
40+
if units >= utf16_units {
41+
return byte_idx;
42+
}
43+
units += ch.len_utf16();
6844
}
45+
s.len()
6946
}
7047

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

7855
impl Drop for Checker {
7956
fn drop(&mut self) {
80-
unsafe {
81-
checker()
82-
.lock()
83-
.unwrap()
84-
.closeSpellDocumentWithTag(self.document_tag)
85-
};
57+
let tag = self.document_tag;
58+
with_checker(|c| c.closeSpellDocumentWithTag(tag));
8659
}
8760
}
8861

8962
impl Checker {
9063
pub fn new() -> Result<Self, Error> {
9164
Ok(Self {
92-
document_tag: unsafe { NSSpellChecker::uniqueSpellDocumentTag(nil) },
65+
document_tag: NSSpellChecker::uniqueSpellDocumentTag(),
9366
language: None,
9467
})
9568
}
9669

9770
pub fn with_locale(_hunspell: &str, bcp47: &str) -> Result<Self, Error> {
98-
// Soft validation: reject empty; unavailable languages still may “work”
99-
// with system fallback — zz_ZZ is only strictly Err on Unix.
10071
if bcp47.is_empty() {
10172
return Err(Error::Unavailable);
10273
}
10374
Ok(Self {
104-
document_tag: unsafe { NSSpellChecker::uniqueSpellDocumentTag(nil) },
75+
document_tag: NSSpellChecker::uniqueSpellDocumentTag(),
10576
language: Some(bcp47.to_owned()),
10677
})
10778
}
@@ -112,34 +83,28 @@ impl Checker {
11283
}
11384

11485
let ns_word = ns_string(word);
115-
let lang = language_id(&self.language);
116-
let length: NSUInteger = unsafe { msg_send![ns_word, length] };
86+
let lang = language_ref(&self.language);
11787
let range = NSRange {
11888
location: 0,
119-
length,
89+
length: word.encode_utf16().count(),
12090
};
121-
122-
let guesses: id = unsafe {
123-
let guard = checker().lock().unwrap();
124-
msg_send![
125-
**guard,
126-
guessesForWordRange: range
127-
inString: ns_word
128-
language: lang
129-
inSpellDocumentWithTag: self.document_tag
130-
]
131-
};
132-
nsarray_to_strings(guesses, MAX)
91+
let tag = self.document_tag;
92+
93+
let guesses = with_checker(|c| {
94+
c.guessesForWordRange_inString_language_inSpellDocumentWithTag(
95+
range,
96+
&ns_word,
97+
lang.as_deref(),
98+
tag,
99+
)
100+
});
101+
nsarray_to_strings(guesses.as_deref(), MAX)
133102
}
134103

135104
pub fn ignore(&mut self, word: &str) {
136-
let word = unsafe { NSString::alloc(nil).init_str(word) };
137-
unsafe {
138-
checker()
139-
.lock()
140-
.unwrap()
141-
.ignoreWord_inSpellDocumentWithTag(word, self.document_tag)
142-
};
105+
let ns_word = ns_string(word);
106+
let tag = self.document_tag;
107+
with_checker(|c| c.ignoreWord_inSpellDocumentWithTag(&ns_word, tag));
143108
}
144109

145110
pub fn check(&mut self, text: &str) -> impl Iterator<Item = SpellingError> {
@@ -148,7 +113,6 @@ impl Checker {
148113
ns_text: ns_string(text),
149114
ns_offset: 0,
150115
original: text.to_owned(),
151-
byte_cursor: 0,
152116
language: self.language.clone(),
153117
}
154118
}
@@ -175,48 +139,46 @@ impl SpellingError {
175139

176140
struct SpellcheckIter {
177141
document_tag: NSInteger,
178-
ns_text: id, /* NSString */
179-
ns_offset: NSUInteger,
142+
ns_text: Retained<NSString>,
143+
ns_offset: usize,
180144
original: String,
181-
byte_cursor: usize,
182145
language: Option<String>,
183146
}
184147

185148
impl Iterator for SpellcheckIter {
186149
type Item = SpellingError;
187150

188151
fn next(&mut self) -> Option<Self::Item> {
189-
let lang = language_id(&self.language);
190-
let (range, _) =
191-
unsafe {
192-
checker().lock().unwrap()
193-
.checkSpellingOfString_startingAt_language_wrap_inSpellDocumentWithTag_wordCount(
194-
self.ns_text,
195-
self.ns_offset as NSInteger,
196-
lang,
197-
NO,
198-
self.document_tag,
199-
)
200-
};
201-
202-
if range.location == NSNotFound as NSUInteger {
152+
let lang = language_ref(&self.language);
153+
let tag = self.document_tag;
154+
let ns_text = self.ns_text.clone();
155+
let starting = self.ns_offset as NSInteger;
156+
157+
let range = with_checker(|c| unsafe {
158+
c.checkSpellingOfString_startingAt_language_wrap_inSpellDocumentWithTag_wordCount(
159+
&ns_text,
160+
starting,
161+
lang.as_deref(),
162+
false,
163+
tag,
164+
ptr::null_mut(),
165+
)
166+
});
167+
168+
if range.length == 0 || range.location == NSNotFound as usize {
203169
return None;
204-
};
170+
}
205171

206-
let misspelling = unsafe {
207-
let misspelling = self.ns_text.substringWithRange(range);
208-
let misspelling_bytes = misspelling.UTF8String() as *const u8;
209-
str::from_utf8(slice::from_raw_parts(misspelling_bytes, misspelling.len())).unwrap()
210-
};
211-
let rest = &self.original[self.byte_cursor..];
212-
let rel = rest.find(misspelling)?;
213-
let start = self.byte_cursor + rel;
214-
let end = start + misspelling.len();
215-
self.byte_cursor = end;
216-
self.ns_offset = range.location + range.length;
172+
let utf16_start = range.location;
173+
let utf16_end = range.location + range.length;
174+
let start = utf16_offset_to_utf8(&self.original, utf16_start);
175+
let end = utf16_offset_to_utf8(&self.original, utf16_end);
176+
let misspelling = self.original[start..end].to_owned();
177+
178+
self.ns_offset = utf16_end;
217179

218180
Some(SpellingError {
219-
text: misspelling.to_owned(),
181+
text: misspelling,
220182
start,
221183
end,
222184
})

0 commit comments

Comments
 (0)