diff --git a/docs/automation/README.md b/docs/automation/README.md index 8c700d7f1..5a50320c5 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -301,10 +301,58 @@ maps onto these three steps rather than a native op. `query` entities of type `TEXT`/`MTEXT` return the raw stored string in `value` plus a formatting-free rendering in `text` (MTEXT inline codes such as `\A1;` or `\P` are resolved; `%%d`-style TEXT codes become their glyphs). With `detail:"full"`, degenerate-width text bounds are widened with a documented estimate (height × 0.8 × character count) so `bounds`-based region filters stay usable. +### Text operations — search, audit and replace + +Three dedicated text operations go beyond `query` for bulk text work: + +**`text_search`** (read) scans all `Text`, `MText`, and `Insert` attribute values for a pattern. It supports case-insensitive, whole-word, and accent-insensitive matching, scoped to a layer, entity type list, or handle set. + +```json +{"op":"text_search","find":"CUVE","match_case":false,"whole_word":true,"ignore_accents":true} +``` + +**`text_audit`** (read) is a non-mutating spell-check and dry-run replacement preview. It accepts three independent analysis modes, all optional, in one call: + +| Parameter | Effect | +|---|---| +| `pairs` | `[{"find":"X","replace":"Y"}, …]` — simulates a batch replacement and reports what would change, without touching the document | +| `check_terms` | `["CIRTUITS","NON TRAITE"]` — flags entities that contain suspect strings | +| `dictionary` | `["EAU","POMPE","VANNE"]` — any word ≥ 3 letters not found in this list is reported as unrecognised | +| `system_spellcheck` | `true` to use the OS native spell-checker alongside or instead of the agent dictionary | +| `language` | BCP-47 tag for the system speller, e.g. `"en-US"` or `"fr-FR"` (defaults to the OS default) | +| `suggest` | `true` (default) to include spelling suggestions for unrecognised words | + +The agent dictionary and system spell-checker work as a layered pipeline: a word that matches the agent dictionary is accepted immediately; only words that fail (or when no dictionary is supplied) are forwarded to the OS spell-checker when enabled. + +System spell-check backends: + +| Platform | Backend | Notes | +|---|---|---| +| Windows 8.1+ | Win32 `ISpellChecker` COM API | Per-language; checks available via Settings → Language. ALL-CAPS words are checked in lowercase since Windows skips all-caps by default. | +| macOS | `NSSpellChecker` | Uses the system language or the `language` parameter | +| Linux | Hunspell `.dic` files or `hunspell` CLI | Reads `/usr/share/hunspell/` dictionaries; falls back to pipe mode | + +Example combining an agent dictionary with system spell-check: + +```json +{ + "op": "text_audit", + "dictionary": ["VANNE", "CHAUDIÈRE", "POMPE"], + "system_spellcheck": true, + "language": "fr-FR", + "suggest": true +} +``` + +The response includes a `system_speller` object with `enabled`, `available`, `backend`, and `language` fields, plus an `unrecognized_words` array with each word's `handle`, `position`, and optional `suggestions`. + +**`text_replace`** (execute) performs the actual batch replacement with single-transaction undo. Add `"dry_run": true` to preview the result without mutating the document or pushing an undo entry. + ### Smoke test The stdio path is covered black-box by a Python script that draws two entities, exports them with `wblock`, plots them with `plot`, and checks the capability advertisement: + ```sh python3 docs/automation/serve_smoke.py target/debug/OpenCADStudio ``` diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index ce195c186..f2af51592 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -1346,6 +1346,7 @@ mod interactive; mod sheets; pub(crate) use sheets::new_guid_v4; pub(crate) mod text_ops; +pub(crate) mod spellcheck; #[cfg(test)] mod p1_tests; diff --git a/src/app/control/p1_tests.rs b/src/app/control/p1_tests.rs index 44c5543ed..3ad5b8b6a 100644 --- a/src/app/control/p1_tests.rs +++ b/src/app/control/p1_tests.rs @@ -763,4 +763,87 @@ fn text_replace_op_supports_dry_run_without_mutating_or_undo() { assert_eq!(q_undone["count"], 1); } +#[test] +fn text_audit_system_spellcheck_flags_misspelled_words() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"sp_c1","document_id":{doc},"entities":[ + {"type":"Text","position":[0,0,0],"value":"The cirtuit breaker is conected","layer":"0"}, + {"type":"Text","position":[10,0,0],"value":"correct english text here","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Run text_audit with system_spellcheck enabled, English language, + // and an agent dictionary that whitelists "breaker" + let audit = app.automation_op( + r#"{ + "op":"text_audit", + "system_spellcheck": true, + "language": "en-US", + "suggest": true, + "dictionary": ["breaker", "the"] + }"#, + ); + assert_eq!(audit["ok"], true, "{}", audit["error"]); + + // system_speller metadata should be present regardless of availability + let sp = &audit["system_speller"]; + assert_eq!(sp["enabled"], true); + + if sp["available"].as_bool() == Some(true) { + // Backend should be reported (e.g. "windows" on Windows) + assert!(sp["backend"].as_str().unwrap().len() > 0); + assert!(sp["language"].as_str().is_some()); + + // "cirtuit" and "conected" should be flagged as unrecognized + let words = audit["unrecognized_words"].as_array().unwrap(); + let flagged: Vec<&str> = words.iter().filter_map(|w| w["word"].as_str()).collect(); + assert!( + flagged.iter().any(|w| w.eq_ignore_ascii_case("cirtuit")), + "expected 'cirtuit' to be flagged, got: {:?}", + flagged + ); + assert!( + flagged.iter().any(|w| w.eq_ignore_ascii_case("conected")), + "expected 'conected' to be flagged, got: {:?}", + flagged + ); + + // "breaker" should NOT be flagged (it's in the agent dictionary) + assert!( + !flagged.iter().any(|w| w.eq_ignore_ascii_case("breaker")), + "expected 'breaker' to be whitelisted by agent dict, got: {:?}", + flagged + ); + + // At least one flagged word should have suggestions + let has_suggestions = words.iter().any(|w| { + w["suggestions"] + .as_array() + .map(|a| !a.is_empty()) + .unwrap_or(false) + }); + assert!( + has_suggestions, + "expected at least one word to have suggestions" + ); + + // Summary should reflect unrecognized words + assert!( + audit["summary"]["unrecognized_words_count"].as_u64().unwrap() >= 2, + "expected at least 2 unrecognized words in summary" + ); + } else { + // System speller not available (e.g. CI without spell-check service) + // Just verify the response structure is well-formed + assert!(audit["unrecognized_words"].is_array()); + eprintln!( + "System spellcheck not available (backend: {}), skipping content assertions", + sp["backend"] + ); + } +} diff --git a/src/app/control/spellcheck.rs b/src/app/control/spellcheck.rs new file mode 100644 index 000000000..8ab3abb23 --- /dev/null +++ b/src/app/control/spellcheck.rs @@ -0,0 +1,767 @@ +//! Native OS spell-checker abstraction supporting Windows, macOS, and Linux. +//! +//! - **Windows**: Windows 8+ native Spell Checking API (`ISpellChecker` via COM). +//! - **macOS**: Apple AppKit `NSSpellChecker` via Objective-C. +//! - **Linux**: Standard system dictionary wordlists (`/usr/share/hunspell`, `/usr/share/myspell`, +//! `/usr/share/dict`) and Hunspell / Aspell pipe integration. +//! - **Other**: Graceful no-op fallback. + +/// Unified system spell checker handle. +pub struct SystemSpeller { + backend: Backend, + language: String, +} + +enum Backend { + #[cfg(target_os = "windows")] + Windows(windows_impl::WindowsSpeller), + #[cfg(target_os = "macos")] + Mac(macos_impl::MacSpeller), + #[cfg(target_os = "linux")] + Linux(linux_impl::LinuxSpeller), + Fallback(fallback_impl::FallbackSpeller), +} + +impl SystemSpeller { + /// Create a system speller for the given BCP-47 language tag (e.g. "fr-FR", "en-US", "de-DE", "es-ES"), + /// or default to the host system's UI locale. + pub fn new(requested_lang: Option<&str>) -> Self { + let lang = normalize_language_tag(requested_lang); + + #[cfg(target_os = "windows")] + { + if let Some(win_speller) = windows_impl::WindowsSpeller::new(&lang) { + let actual_lang = win_speller.language().to_string(); + return Self { + backend: Backend::Windows(win_speller), + language: actual_lang, + }; + } + } + + #[cfg(target_os = "macos")] + { + if let Some(mac_speller) = macos_impl::MacSpeller::new(&lang) { + let actual_lang = mac_speller.language().to_string(); + return Self { + backend: Backend::Mac(mac_speller), + language: actual_lang, + }; + } + } + + #[cfg(target_os = "linux")] + { + if let Some(linux_speller) = linux_impl::LinuxSpeller::new(&lang) { + let actual_lang = linux_speller.language().to_string(); + return Self { + backend: Backend::Linux(linux_speller), + language: actual_lang, + }; + } + } + + Self { + backend: Backend::Fallback(fallback_impl::FallbackSpeller), + language: lang, + } + } + + /// Whether a native system spell-checker is active and available. + pub fn is_available(&self) -> bool { + match &self.backend { + #[cfg(target_os = "windows")] + Backend::Windows(_) => true, + #[cfg(target_os = "macos")] + Backend::Mac(_) => true, + #[cfg(target_os = "linux")] + Backend::Linux(l) => l.is_available(), + Backend::Fallback(_) => false, + } + } + + /// Returns the active backend name ("windows", "macos", "linux_system", or "none"). + pub fn backend_name(&self) -> &'static str { + match &self.backend { + #[cfg(target_os = "windows")] + Backend::Windows(_) => "windows", + #[cfg(target_os = "macos")] + Backend::Mac(_) => "macos", + #[cfg(target_os = "linux")] + Backend::Linux(_) => "linux_system", + Backend::Fallback(_) => "none", + } + } + + /// The active language tag (e.g. "fr-FR", "en-US"). + pub fn language(&self) -> &str { + &self.language + } + + /// Check if a word is spelled correctly according to the system spell-checker. + /// + /// Returns `(is_correct, suggestions)`. + /// When `is_correct` is false, `suggestions` contains system-suggested corrections. + pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec) { + if word.trim().is_empty() { + return (true, Vec::new()); + } + + match &self.backend { + #[cfg(target_os = "windows")] + Backend::Windows(w) => w.check_word(word, want_suggestions), + #[cfg(target_os = "macos")] + Backend::Mac(m) => m.check_word(word, want_suggestions), + #[cfg(target_os = "linux")] + Backend::Linux(l) => l.check_word(word, want_suggestions), + Backend::Fallback(_) => (true, Vec::new()), + } + } +} + +fn normalize_language_tag(input: Option<&str>) -> String { + let raw: String = match input { + Some(s) if !s.trim().is_empty() => s.trim().to_string(), + _ => { + #[cfg(target_os = "windows")] + { + let mut buf = [0u16; 85]; + let n = unsafe { windows_impl::GetUserDefaultLocaleName(buf.as_mut_ptr(), 85) }; + if n > 1 { + String::from_utf16_lossy(&buf[..(n - 1) as usize]) + } else { + "en-US".to_string() + } + } + #[cfg(not(target_os = "windows"))] + { + if let Ok(lang) = std::env::var("LANG").or_else(|_| std::env::var("LC_ALL")) { + let cleaned = lang.split('.').next().unwrap_or("").replace('_', "-"); + if !cleaned.is_empty() { + cleaned + } else { + "en-US".to_string() + } + } else { + "en-US".to_string() + } + } + } + }; + + let lower = raw.to_lowercase(); + match lower.as_str() { + "fr" | "french" | "francais" | "français" => "fr-FR".to_string(), + "en" | "english" => "en-US".to_string(), + "de" | "german" | "deutsch" => "de-DE".to_string(), + "es" | "spanish" | "espanol" | "español" => "es-ES".to_string(), + "it" | "italian" | "italiano" => "it-IT".to_string(), + _ => raw.to_string(), + } +} + +// ============================================================================ +// Windows Backend: Win32 Spell Checking API (ISpellChecker via COM) +// ============================================================================ +#[cfg(target_os = "windows")] +pub(crate) mod windows_impl { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + + type HRESULT = i32; + type BOOL = i32; + + #[repr(C)] + struct GUID { + data1: u32, + data2: u16, + data3: u16, + data4: [u8; 8], + } + + const CLSID_SPELL_CHECKER_FACTORY: GUID = GUID { + data1: 0x7ab36653, + data2: 0x1796, + data3: 0x484b, + data4: [0xbd, 0xfa, 0xe7, 0x4f, 0x1d, 0xb7, 0xc1, 0xdc], + }; + + const IID_ISPELL_CHECKER_FACTORY: GUID = GUID { + data1: 0x8e018a9d, + data2: 0x2415, + data3: 0x4677, + data4: [0xbf, 0x08, 0x79, 0x4e, 0xa6, 0x1f, 0x94, 0xbb], + }; + + #[repr(C)] + struct ISpellCheckerFactoryVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + get_supported_languages: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> HRESULT, + is_supported: unsafe extern "system" fn(*mut c_void, *const u16, *mut BOOL) -> HRESULT, + create_spell_checker: unsafe extern "system" fn(*mut c_void, *const u16, *mut *mut c_void) -> HRESULT, + } + + #[repr(C)] + struct ISpellCheckerVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + get_language_tag: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT, + check: unsafe extern "system" fn(*mut c_void, *const u16, *mut *mut c_void) -> HRESULT, + suggest: unsafe extern "system" fn(*mut c_void, *const u16, *mut *mut c_void) -> HRESULT, + add: unsafe extern "system" fn(*mut c_void, *const u16) -> HRESULT, + ignore: unsafe extern "system" fn(*mut c_void, *const u16) -> HRESULT, + auto_correct: unsafe extern "system" fn(*mut c_void, *const u16, *const u16) -> HRESULT, + get_option_value: unsafe extern "system" fn(*mut c_void, *const u16, *mut u8) -> HRESULT, + get_option_ids: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> HRESULT, + get_id: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT, + get_localized_name: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT, + } + + #[repr(C)] + struct IEnumSpellingErrorVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + next: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> HRESULT, + } + + #[repr(C)] + struct ISpellingErrorVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + get_start_index: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT, + get_length: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT, + get_corrective_action: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT, + get_replacement: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT, + } + + #[repr(C)] + struct IEnumStringVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + next: unsafe extern "system" fn(*mut c_void, u32, *mut *mut u16, *mut u32) -> HRESULT, + } + + #[link(name = "ole32")] + extern "system" { + fn CoInitializeEx(pv_reserved: *const c_void, dw_co_init: u32) -> HRESULT; + fn CoCreateInstance( + rclsid: *const GUID, + p_unk_outer: *mut c_void, + dw_cls_context: u32, + riid: *const GUID, + ppv: *mut *mut c_void, + ) -> HRESULT; + fn CoTaskMemFree(pv: *mut c_void); + } + + #[link(name = "kernel32")] + extern "system" { + pub fn GetUserDefaultLocaleName(lp_locale_name: *mut u16, cch_locale_name: i32) -> i32; + } + + fn to_wide(s: &str) -> Vec { + std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect() + } + + unsafe fn from_wide_ptr(ptr: *mut u16) -> String { + if ptr.is_null() { + return String::new(); + } + let mut len = 0; + while *ptr.add(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(ptr, len); + let s = String::from_utf16_lossy(slice); + CoTaskMemFree(ptr as *mut c_void); + s + } + + pub struct WindowsSpeller { + checker_obj: *mut c_void, + language: String, + } + + // Windows COM objects created with CLSCTX_INPROC_SERVER are safe to invoke from our control thread. + unsafe impl Send for WindowsSpeller {} + unsafe impl Sync for WindowsSpeller {} + + impl WindowsSpeller { + pub fn new(lang_tag: &str) -> Option { + unsafe { + CoInitializeEx(std::ptr::null(), 0); + + let mut factory_obj: *mut c_void = std::ptr::null_mut(); + let hr = CoCreateInstance( + &CLSID_SPELL_CHECKER_FACTORY, + std::ptr::null_mut(), + 1, // CLSCTX_INPROC_SERVER + &IID_ISPELL_CHECKER_FACTORY, + &mut factory_obj, + ); + if hr != 0 || factory_obj.is_null() { + return None; + } + + let factory_vtbl = *(factory_obj as *const *const ISpellCheckerFactoryVtbl); + + // Try requested language, then fallback languages + let mut candidates = vec![lang_tag.to_string()]; + if !candidates.contains(&"en-US".to_string()) { + candidates.push("en-US".to_string()); + } + + let mut chosen_checker = None; + let mut chosen_lang = String::new(); + + for candidate in candidates { + let wide_cand = to_wide(&candidate); + let mut supported: BOOL = 0; + let hr_supp = ((*factory_vtbl).is_supported)( + factory_obj, + wide_cand.as_ptr(), + &mut supported, + ); + if hr_supp == 0 && supported != 0 { + let mut checker_obj: *mut c_void = std::ptr::null_mut(); + let hr_create = ((*factory_vtbl).create_spell_checker)( + factory_obj, + wide_cand.as_ptr(), + &mut checker_obj, + ); + if hr_create == 0 && !checker_obj.is_null() { + chosen_checker = Some(checker_obj); + chosen_lang = candidate; + break; + } + } + } + + ((*factory_vtbl).release)(factory_obj); + + chosen_checker.map(|obj| Self { + checker_obj: obj, + language: chosen_lang, + }) + } + } + + pub fn language(&self) -> &str { + &self.language + } + + pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec) { + if self.checker_obj.is_null() { + return (true, Vec::new()); + } + + unsafe { + let checker_vtbl = *(self.checker_obj as *const *const ISpellCheckerVtbl); + let wide_text = to_wide(word); + + let has_err_exact = has_spelling_error(checker_vtbl, self.checker_obj, &wide_text); + + let is_all_caps = word.chars().any(char::is_alphabetic) + && word == word.to_uppercase(); + + let mut is_misspelled = has_err_exact; + + // Windows spellcheck by default ignores all-caps words (to avoid flagging acronyms). + // In CAD drawings, virtually all text is uppercase (e.g. CIRTUITS, VANNE). + // If it wasn't flagged in all-caps, test its lowercase form to check if it's a real word. + if !is_misspelled && is_all_caps { + let lower = word.to_lowercase(); + let wide_lower = to_wide(&lower); + if has_spelling_error(checker_vtbl, self.checker_obj, &wide_lower) { + is_misspelled = true; + } + } + + let mut suggestions = Vec::new(); + if is_misspelled && want_suggestions { + // Query suggestions for word and/or its lowercase counterpart + let mut enum_str: *mut c_void = std::ptr::null_mut(); + let target_wide = if is_all_caps { + to_wide(&word.to_lowercase()) + } else { + wide_text.clone() + }; + + if ((*checker_vtbl).suggest)(self.checker_obj, target_wide.as_ptr(), &mut enum_str) == 0 + && !enum_str.is_null() + { + let enum_vtbl = *(enum_str as *const *const IEnumStringVtbl); + loop { + let mut str_ptr: *mut u16 = std::ptr::null_mut(); + let mut fetched: u32 = 0; + if ((*enum_vtbl).next)(enum_str, 1, &mut str_ptr, &mut fetched) != 0 + || fetched == 0 + { + break; + } + let s = from_wide_ptr(str_ptr); + if !s.is_empty() { + // Match uppercase if original word was uppercase + let final_s = if is_all_caps { s.to_uppercase() } else { s }; + if !suggestions.contains(&final_s) { + suggestions.push(final_s); + } + } + if suggestions.len() >= 5 { + break; + } + } + ((*enum_vtbl).release)(enum_str); + } + } + + (!is_misspelled, suggestions) + } + } + } + + unsafe fn has_spelling_error( + checker_vtbl: *const ISpellCheckerVtbl, + checker_obj: *mut c_void, + wide_text: &[u16], + ) -> bool { + let mut errors_obj: *mut c_void = std::ptr::null_mut(); + let hr_check = ((*checker_vtbl).check)(checker_obj, wide_text.as_ptr(), &mut errors_obj); + let mut has_error = false; + if hr_check == 0 && !errors_obj.is_null() { + let enum_vtbl = *(errors_obj as *const *const IEnumSpellingErrorVtbl); + let mut err_item: *mut c_void = std::ptr::null_mut(); + if ((*enum_vtbl).next)(errors_obj, &mut err_item) == 0 && !err_item.is_null() { + has_error = true; + ((*(*(err_item as *const *const ISpellingErrorVtbl))).release)(err_item); + } + ((*enum_vtbl).release)(errors_obj); + } + has_error + } + + impl Drop for WindowsSpeller { + fn drop(&mut self) { + if !self.checker_obj.is_null() { + unsafe { + let vtbl = *(self.checker_obj as *const *const ISpellCheckerVtbl); + ((*vtbl).release)(self.checker_obj); + } + } + } + } +} + +// ============================================================================ +// macOS Backend: NSSpellChecker via AppKit +// ============================================================================ +#[cfg(target_os = "macos")] +pub(crate) mod macos_impl { + use objc2::msg_send; + use objc2::runtime::{AnyClass, AnyObject}; + use objc2_foundation::NSString; + + pub struct MacSpeller { + checker: *mut AnyObject, + language: String, + } + + unsafe impl Send for MacSpeller {} + unsafe impl Sync for MacSpeller {} + + impl MacSpeller { + pub fn new(lang_tag: &str) -> Option { + unsafe { + let cls = AnyClass::get("NSSpellChecker")?; + let checker: *mut AnyObject = msg_send![cls, sharedSpellChecker]; + if checker.is_null() { + return None; + } + + let ns_lang = NSString::from_str(lang_tag); + let ok: bool = msg_send![checker, setLanguage: &*ns_lang]; + let actual_lang = if ok { + lang_tag.to_string() + } else { + let cur_lang: *mut AnyObject = msg_send![checker, language]; + if !cur_lang.is_null() { + let cur: &NSString = &*(cur_lang as *const NSString); + cur.to_string() + } else { + lang_tag.to_string() + } + }; + + Some(Self { + checker, + language: actual_lang, + }) + } + } + + pub fn language(&self) -> &str { + &self.language + } + + pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec) { + if self.checker.is_null() || word.is_empty() { + return (true, Vec::new()); + } + + unsafe { + let is_all_caps = word.chars().any(char::is_alphabetic) + && word == word.to_uppercase(); + + let ns_word = NSString::from_str(word); + let range: objc2_foundation::NSRange = msg_send![ + self.checker, + checkSpellingOfString: &*ns_word, + startingAt: 0isize, + language: std::ptr::null::(), + wrap: false, + inSpellDocumentWithTag: 0isize, + wordCount: std::ptr::null_mut::() + ]; + + let mut is_misspelled = range.length > 0; + + // Also check lowercase if all-caps + if !is_misspelled && is_all_caps { + let lower = word.to_lowercase(); + let ns_lower = NSString::from_str(&lower); + let range_lower: objc2_foundation::NSRange = msg_send![ + self.checker, + checkSpellingOfString: &*ns_lower, + startingAt: 0isize, + language: std::ptr::null::(), + wrap: false, + inSpellDocumentWithTag: 0isize, + wordCount: std::ptr::null_mut::() + ]; + if range_lower.length > 0 { + is_misspelled = true; + } + } + + let mut suggestions = Vec::new(); + if is_misspelled && want_suggestions { + let target_str = if is_all_caps { word.to_lowercase() } else { word.to_string() }; + let ns_target = NSString::from_str(&target_str); + let word_len = target_str.encode_utf16().count(); + let r = objc2_foundation::NSRange { + location: 0, + length: word_len, + }; + let arr: *mut AnyObject = msg_send![ + self.checker, + guessesForWordRange: r, + inString: &*ns_target, + language: std::ptr::null::(), + inSpellDocumentWithTag: 0isize + ]; + if !arr.is_null() { + let count: usize = msg_send![arr, count]; + for i in 0..count.min(5) { + let item: *mut AnyObject = msg_send![arr, objectAtIndex: i]; + if !item.is_null() { + let ns_item: &NSString = &*(item as *const NSString); + let mut s = ns_item.to_string(); + if is_all_caps { + s = s.to_uppercase(); + } + suggestions.push(s); + } + } + } + } + + (!is_misspelled, suggestions) + } + } + } +} + +// ============================================================================ +// Linux Backend: System Dictionaries & Hunspell / Aspell +// ============================================================================ +#[cfg(target_os = "linux")] +pub(crate) mod linux_impl { + use std::collections::HashSet; + use std::fs::File; + use std::io::{BufRead, BufReader}; + use std::path::{Path, PathBuf}; + + const LINUX_DICT_DIRS: &[&str] = &[ + "/usr/share/hunspell", + "/usr/share/myspell", + "/usr/share/myspell/dicts", + "/var/lib/dictionaries-common/hunspell_dic", + "/usr/share/dict", + ]; + + pub struct LinuxSpeller { + words: HashSet, + language: String, + has_cli_tool: bool, + } + + impl LinuxSpeller { + pub fn new(lang_tag: &str) -> Option { + let mut words = HashSet::new(); + let mut found_any = false; + + // Search system directories for .dic or wordlist files matching the requested language + let short_lang = lang_tag.split('-').next().unwrap_or(lang_tag); + let candidates = [ + format!("{}.dic", lang_tag.replace('-', "_")), + format!("{}.dic", lang_tag), + format!("{}.dic", short_lang), + "words".to_string(), + short_lang.to_string(), + ]; + + for dir in LINUX_DICT_DIRS { + let base = Path::new(dir); + if !base.is_dir() { + continue; + } + for candidate in &candidates { + let path = base.join(candidate); + if path.is_file() { + if load_wordlist(&path, &mut words) { + found_any = true; + break; + } + } + } + if found_any { + break; + } + } + + let has_cli = is_command_in_path("hunspell") || is_command_in_path("aspell"); + + if !found_any && !has_cli { + return None; + } + + Some(Self { + words, + language: lang_tag.to_string(), + has_cli_tool: has_cli, + }) + } + + pub fn is_available(&self) -> bool { + !self.words.is_empty() || self.has_cli_tool + } + + pub fn language(&self) -> &str { + &self.language + } + + pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec) { + let clean = word.trim().to_lowercase(); + if clean.is_empty() { + return (true, Vec::new()); + } + + if self.words.contains(&clean) { + return (true, Vec::new()); + } + + // If CLI hunspell is available, fallback to pipe check + if self.has_cli_tool { + if let Some((valid, suggs)) = check_via_cli(word, &self.language, want_suggestions) { + return (valid, suggs); + } + } + + (!self.words.is_empty() && !self.words.contains(&clean), Vec::new()) + } + } + + fn load_wordlist(path: &Path, words: &mut HashSet) -> bool { + let Ok(file) = File::open(path) else { return false }; + let reader = BufReader::new(file); + for line in reader.lines().flatten() { + let entry = line.trim(); + if entry.is_empty() || entry.starts_with('#') { + continue; + } + // Strip Hunspell flags: "word/flags" + let word_part = entry.split('/').next().unwrap_or(entry).trim(); + if !word_part.is_empty() && !word_part.chars().all(|c| c.is_ascii_digit()) { + words.insert(word_part.to_lowercase()); + } + } + !words.is_empty() + } + + fn is_command_in_path(cmd: &str) -> bool { + if let Ok(path_var) = std::env::var("PATH") { + for dir in std::env::split_paths(&path_var) { + if dir.join(cmd).is_file() { + return true; + } + } + } + false + } + + fn check_via_cli(word: &str, lang: &str, want_suggestions: bool) -> Option<(bool, Vec)> { + use std::io::Write; + use std::process::{Command, Stdio}; + + let lang_param = lang.replace('-', "_"); + let mut child = Command::new("hunspell") + .arg("-d") + .arg(&lang_param) + .arg("-a") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + if let Some(mut stdin) = child.stdin.take() { + let _ = writeln!(stdin, "^{word}"); + } + + let output = child.wait_with_output().ok()?; + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.starts_with('*') || line.starts_with('+') || line.starts_with('-') { + return Some((true, Vec::new())); + } + if line.starts_with('&') { + let mut suggs = Vec::new(); + if want_suggestions { + if let Some(pos) = line.find(':') { + for s in line[pos + 1..].split(',') { + let item = s.trim(); + if !item.is_empty() && suggs.len() < 5 { + suggs.push(item.to_string()); + } + } + } + } + return Some((false, suggs)); + } + } + None + } +} + +// ============================================================================ +// Fallback Backend +// ============================================================================ +mod fallback_impl { + pub struct FallbackSpeller; +} diff --git a/src/app/control/text_ops.rs b/src/app/control/text_ops.rs index e331c45e4..33ffe7256 100644 --- a/src/app/control/text_ops.rs +++ b/src/app/control/text_ops.rs @@ -955,6 +955,20 @@ impl OpenCADStudio { .map(|w| TextMatcher::new(w.clone(), false, true, true)) .collect(); + let use_system_speller = req["system_spellcheck"] + .as_bool() + .or_else(|| req["use_system_speller"].as_bool()) + .or_else(|| req["system_speller"].as_bool()) + .unwrap_or(false); + let language_param = req["language"].as_str(); + let want_suggestions = req["suggest"].as_bool().unwrap_or(true); + + let system_speller = if use_system_speller { + Some(super::spellcheck::SystemSpeller::new(language_param)) + } else { + None + }; + let mut pair_hit_counts = vec![0usize; pair_matchers.len()]; let mut check_hit_counts = vec![0usize; check_matchers.len()]; @@ -1090,21 +1104,39 @@ impl OpenCADStudio { } } - // 3. Dictionary check - if !dict_matchers.is_empty() { + // 3. Dictionary & System Spell-check + if !dict_matchers.is_empty() || system_speller.is_some() { for word in plain_text.split(|c: char| !c.is_alphabetic()) { let w = word.trim(); - if w.len() >= 3 && !dict_matchers.iter().any(|dm| dm.matches(w)) { - if unrecognized_words.len() < limit - && !unrecognized_words - .iter() - .any(|item: &Value| item["word"].as_str() == Some(w)) - { - unrecognized_words.push(json!({ - "word": w, - "handle": format!("{:X}", handle.value()), - "position": pos, - })); + if w.len() >= 3 { + let in_agent_dict = dict_matchers.iter().any(|dm| dm.matches(w)); + if in_agent_dict { + continue; + } + + let (is_system_valid, suggestions) = if let Some(ref speller) = system_speller { + speller.check_word(w, want_suggestions) + } else { + (false, Vec::new()) + }; + + if !is_system_valid { + entity_had_match = true; + if unrecognized_words.len() < limit + && !unrecognized_words + .iter() + .any(|item: &Value| item["word"].as_str() == Some(w)) + { + let mut item = json!({ + "word": w, + "handle": format!("{:X}", handle.value()), + "position": pos, + }); + if !suggestions.is_empty() { + item["suggestions"] = json!(suggestions); + } + unrecognized_words.push(item); + } } } } @@ -1157,6 +1189,44 @@ impl OpenCADStudio { } } } + + if !dict_matchers.is_empty() || system_speller.is_some() { + for word in plain.split(|c: char| !c.is_alphabetic()) { + let w = word.trim(); + if w.len() >= 3 { + let in_agent_dict = dict_matchers.iter().any(|dm| dm.matches(w)); + if in_agent_dict { + continue; + } + + let (is_system_valid, suggestions) = if let Some(ref speller) = system_speller { + speller.check_word(w, want_suggestions) + } else { + (false, Vec::new()) + }; + + if !is_system_valid { + entity_had_match = true; + if unrecognized_words.len() < limit + && !unrecognized_words + .iter() + .any(|item: &Value| item["word"].as_str() == Some(w)) + { + let mut item = json!({ + "word": w, + "handle": format!("{:X}", handle.value()), + "attribute_tag": attr.tag, + "position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z], + }); + if !suggestions.is_empty() { + item["suggestions"] = json!(suggestions); + } + unrecognized_words.push(item); + } + } + } + } + } } } @@ -1180,6 +1250,22 @@ impl OpenCADStudio { .map(|(idx, _)| check_terms[idx].clone()) .collect(); + let speller_info = if let Some(ref speller) = system_speller { + json!({ + "enabled": true, + "available": speller.is_available(), + "backend": speller.backend_name(), + "language": speller.language(), + }) + } else { + json!({ + "enabled": false, + "available": false, + "backend": "none", + "language": null, + }) + }; + Ok(json!({ "ok": true, "summary": { @@ -1190,7 +1276,9 @@ impl OpenCADStudio { "pairs_unmatched": unmatched_pairs.len(), "check_terms_total": check_terms.len(), "check_terms_found": check_terms.len() - unmatched_check_terms.len(), + "unrecognized_words_count": unrecognized_words.len(), }, + "system_speller": speller_info, "unmatched_pairs": unmatched_pairs, "unmatched_check_terms": unmatched_check_terms, "simulated_changes": simulated_changes, diff --git a/src/mcp.rs b/src/mcp.rs index 4540ffaba..b3f9eeaeb 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1225,7 +1225,7 @@ fn tool_definitions() -> Value { { "name":"ocs_read", "description":"Discover capabilities and record schemas, or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", - "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"check_terms":{"type":"array","items":{"type":"string"},"description":"List of terms or suspect misspellings to flag in text_audit."},"dictionary":{"type":"array","items":{"type":"string"},"description":"Known valid words for text_audit dictionary check."},"pairs":{"type":"array","description":"Find/replace pairs for dry-run simulation in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"dry_run_pairs":{"type":"array","description":"Alias for pairs in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, + "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"system_spellcheck":{"type":"boolean","default":false,"description":"Use native OS spell-checker (Windows, macOS, Linux)."},"language":{"type":"string","description":"Language tag for spell-checker (e.g. 'fr-FR', 'en-US', 'de-DE', 'es-ES')."},"suggest":{"type":"boolean","default":true,"description":"Include suggested corrections for misspelled words."},"check_terms":{"type":"array","items":{"type":"string"},"description":"List of terms or suspect misspellings to flag in text_audit."},"dictionary":{"type":"array","items":{"type":"string"},"description":"Known valid words for text_audit dictionary check."},"pairs":{"type":"array","description":"Find/replace pairs for dry-run simulation in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"dry_run_pairs":{"type":"array","description":"Alias for pairs in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, "outputSchema":read_output_schema(), "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} },