Merge remote-tracking branch 'origin/main' into codex/pointcloud-attach

# Conflicts:
#	Cargo.lock
This commit is contained in:
ramox81 committed 2026-09-30 09:15:26 +03:00
commit 3afd547e28
37 files changed
+4603 -302

No files matched your search

+3
View File
@@ -679,6 +679,9 @@ impl OpenCADStudio {
"entities" => self.entity_summary(),
"audit" => self.document_audit(&req),
"query" => self.entity_query(&req),
"text_search" => self.automation_text_search(&req),
"text_audit" => self.automation_text_audit(&req),
"text_replace" => self.automation_text_replace(&req),
"records" => self.record_query(&req),
"record_schema" => self.record_schema(&req),
"capabilities" => self.record_capabilities(),
+1
View File
@@ -391,6 +391,7 @@ impl OpenCADStudio {
});
match seed {
Some((s, tangent)) => {
self.last_point = Some(s);
let new_cmd = ArcContCommand::new(s, tangent);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
+7
View File
@@ -471,6 +471,8 @@ impl OpenCADStudio {
| "xdata_get"
| "get_selection"
| "audit"
| "text_search"
| "text_audit"
);
if !query && !self.control.enabled {
return (
@@ -729,6 +731,8 @@ impl OpenCADStudio {
}
"xdata_get" => self.xdata_read(&req),
"get_selection" => self.control_get_selection(),
"text_search" => self.control_text_search(&req).unwrap_or_else(|e| e),
"text_audit" => self.control_text_audit(&req).unwrap_or_else(|e| e),
"history" => {
json!({"ok":true,"entries":self.command_line.history.iter().map(|e|json!({"kind":format!("{:?}",e.kind),"text":e.text})).collect::<Vec<_>>()})
}
@@ -975,6 +979,7 @@ impl OpenCADStudio {
"entities_delete" => self.control_entities_delete(req)?,
"entities_transform" => self.control_entities_transform(req)?,
"entities_copy_to" => self.control_entities_copy_to(req)?,
"text_replace" => self.control_text_replace(req)?,
"xdata_set" => self.control_xdata_set(req)?,
"block_define" => self.control_block_define(req)?,
"block_delete" => self.control_block_delete(req)?,
@@ -1340,6 +1345,8 @@ pub(crate) mod http_bridge;
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;
+283
View File
@@ -566,3 +566,286 @@ fn state_reports_the_hand_seed() {
let after = u64::from_str_radix(state["hand_seed"].as_str().unwrap(), 16).unwrap();
assert!(after > before, "issuing a handle advances the seed");
}
#[test]
fn text_search_op_queries_text_and_attributes() {
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":"ts_c1","document_id":{doc},"entities":[
{"type":"Text","position":[0,0,0],"value":"CIRTUITS 28-09-2026","layer":"0"},
{"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic;POMPE A EAU}","layer":"0"},
{"type":"Text","position":[20,0,0],"value":"BAROMETRE","layer":"0"},
{"type":"Text","position":[30,0,0],"value":"10 BAR","layer":"0"}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
// Case-insensitive search finds CIRTUITS
let res = app.automation_op(r#"{"op":"text_search","find":"cirtuits"}"#);
assert_eq!(res["ok"], true);
assert_eq!(res["count"], 1);
assert_eq!(res["matches"][0]["match_text"], "cirtuits");
assert_eq!(res["matches"][0]["plain_text"], "CIRTUITS 28-09-2026");
// Case-sensitive search with wrong case finds nothing
let res_cs = app.automation_op(r#"{"op":"text_search","find":"cirtuits","match_case":true}"#);
assert_eq!(res_cs["ok"], true);
assert_eq!(res_cs["count"], 0);
// Whole-word search: "bar" matches "10 BAR" but not "BAROMETRE"
let res_ww = app.automation_op(r#"{"op":"text_search","find":"bar","whole_word":true}"#);
assert_eq!(res_ww["ok"], true);
assert_eq!(res_ww["count"], 1);
assert_eq!(res_ww["matches"][0]["plain_text"], "10 BAR");
// Non-whole-word search finds both "BAROMETRE" and "10 BAR"
let res_all = app.automation_op(r#"{"op":"text_search","find":"bar","whole_word":false}"#);
assert_eq!(res_all["ok"], true);
assert_eq!(res_all["count"], 2);
}
#[test]
fn text_replace_op_batch_replaces_preserves_formatting_and_undoes() {
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":"tr_c1","document_id":{doc},"entities":[
{"type":"Text","position":[0,0,0],"value":"VANNE A ARRET","layer":"0"},
{"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic|b0|i0;V15-CUVE EAU NON TRAITE}","layer":"0"}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
// Execute batch replace with 2 pairs in one atomic step
let rep = mutate(
&mut app,
r#"{"protocol":1,"op":"text_replace","request_id":"tr_r1","document_id":{doc},"pairs":[
{"find":"NON TRAITE","replace":"NON TRAITÉE"},
{"find":"VANNE A ARRET","replace":"VANNE D'ARRÊT"}
]}"#,
);
assert_eq!(rep["ok"], true, "{}", rep["error"]);
assert_eq!(rep["result"]["replaced"], 2);
assert_eq!(rep["result"]["entities_changed"], 2);
// Verify MText formatting codes were preserved
let q = app.automation_op(r#"{"op":"text_search","find":"NON TRAITÉE"}"#);
assert_eq!(q["ok"], true);
assert_eq!(q["count"], 1);
let raw = q["matches"][0]["raw_value"].as_str().unwrap();
assert!(raw.contains("Century Gothic"), "raw MText should keep font format code: {raw}");
assert!(raw.contains("NON TRAITÉE"), "raw MText should contain replaced text: {raw}");
// Verify Text was replaced
let q2 = app.automation_op(r#"{"op":"text_search","find":"VANNE D'ARRÊT"}"#);
assert_eq!(q2["ok"], true);
assert_eq!(q2["count"], 1);
// Undo restores the original text in one step
app.automation_op(r#"{"op":"undo"}"#);
let q_orig = app.automation_op(r#"{"op":"text_search","find":"NON TRAITE"}"#);
assert_eq!(q_orig["ok"], true);
assert_eq!(q_orig["count"], 1);
let q_orig2 = app.automation_op(r#"{"op":"text_search","find":"VANNE A ARRET"}"#);
assert_eq!(q_orig2["ok"], true);
assert_eq!(q_orig2["count"], 1);
}
#[test]
fn text_audit_op_simulates_dry_run_checks_terms_and_dictionary() {
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":"ta_c1","document_id":{doc},"entities":[
{"type":"Text","position":[0,0,0],"value":"CIRTUITS 28-09-2026","layer":"0"},
{"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic;CUVE EAU NON TRAITE}","layer":"0"},
{"type":"Text","position":[20,0,0],"value":"POMPE A EAU","layer":"0"}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
// Test text_audit with pairs, check_terms, and dictionary
let audit_res = app.automation_op(
r#"{
"op":"text_audit",
"pairs":[
{"find":"CIRTUITS","replace":"CIRCUITS"},
{"find":"NON TRAITE","replace":"NON TRAITÉE"},
{"find":"INEXISTANT_PAIR","replace":"REPLACEMENT"}
],
"check_terms":["CIRTUITS", "NON TRAITE", "INEXISTANT_TERM"],
"dictionary":["EAU", "POMPE", "28-09-2026"]
}"#,
);
assert_eq!(audit_res["ok"], true, "{}", audit_res["error"]);
// Check summary
assert_eq!(audit_res["summary"]["pairs_total"], 3);
assert_eq!(audit_res["summary"]["pairs_unmatched"], 1);
assert_eq!(audit_res["summary"]["check_terms_total"], 3);
assert_eq!(audit_res["summary"]["check_terms_found"], 2);
// Unmatched reporting
assert_eq!(audit_res["unmatched_pairs"], json!(["INEXISTANT_PAIR"]));
assert_eq!(audit_res["unmatched_check_terms"], json!(["INEXISTANT_TERM"]));
// Simulated changes has 2 entries (Text and MText)
let changes = audit_res["simulated_changes"].as_array().unwrap();
assert_eq!(changes.len(), 2);
assert_eq!(changes[0]["find"], "CIRTUITS");
assert_eq!(changes[0]["replace"], "CIRCUITS");
assert_eq!(changes[0]["replaced"], 1);
assert!(changes[1]["after"].as_str().unwrap().contains("NON TRAITÉE"));
// Suspect matches
let suspects = audit_res["suspect_matches"].as_array().unwrap();
assert_eq!(suspects.len(), 2);
// Verify document was NOT mutated (pure read op)
let q = app.automation_op(r#"{"op":"text_search","find":"CIRTUITS"}"#);
assert_eq!(q["ok"], true);
assert_eq!(q["count"], 1);
}
#[test]
fn text_replace_op_supports_dry_run_without_mutating_or_undo() {
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":"tr_dry1","document_id":{doc},"entities":[
{"type":"Text","position":[0,0,0],"value":"TEST BEFORE REPLACE","layer":"0"}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
// Run text_replace with dry_run = true
let dry_res = mutate(
&mut app,
r#"{"protocol":1,"op":"text_replace","request_id":"tr_sim1","document_id":{doc},"find":"BEFORE","replace":"AFTER","dry_run":true}"#,
);
assert_eq!(dry_res["ok"], true, "{}", dry_res["error"]);
assert_eq!(dry_res["result"]["dry_run"], true);
assert_eq!(dry_res["result"]["replaced"], 1);
assert_eq!(dry_res["result"]["entities_changed"], 1);
// Verify the document was NOT modified
let q_before = app.automation_op(r#"{"op":"text_search","find":"TEST BEFORE REPLACE"}"#);
assert_eq!(q_before["ok"], true);
assert_eq!(q_before["count"], 1);
let q_after = app.automation_op(r#"{"op":"text_search","find":"TEST AFTER REPLACE"}"#);
assert_eq!(q_after["ok"], true);
assert_eq!(q_after["count"], 0);
// Now run with dry_run = false
let real_res = mutate(
&mut app,
r#"{"protocol":1,"op":"text_replace","request_id":"tr_real1","document_id":{doc},"find":"BEFORE","replace":"AFTER","dry_run":false}"#,
);
assert_eq!(real_res["ok"], true, "{}", real_res["error"]);
assert_eq!(real_res["result"]["replaced"], 1);
// Now it was modified
let q_after2 = app.automation_op(r#"{"op":"text_search","find":"TEST AFTER REPLACE"}"#);
assert_eq!(q_after2["ok"], true);
assert_eq!(q_after2["count"], 1);
// Undo restores it
app.automation_op(r#"{"op":"undo"}"#);
let q_undone = app.automation_op(r#"{"op":"text_search","find":"TEST BEFORE REPLACE"}"#);
assert_eq!(q_undone["ok"], true);
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 — except from a
// bare Linux wordlist, which can tell a word is unknown but has no
// way to suggest one without the hunspell CLI.
let has_suggestions = words.iter().any(|w| {
w["suggestions"]
.as_array()
.map(|a| !a.is_empty())
.unwrap_or(false)
});
assert!(
has_suggestions || sp["backend"] == "linux_system",
"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"]
);
}
}
+770
View File
@@ -0,0 +1,770 @@
//! 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<String>) {
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<u16> {
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<Self> {
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<String>) {
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<Self> {
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<String>) {
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::<AnyObject>(),
wrap: false,
inSpellDocumentWithTag: 0isize,
wordCount: std::ptr::null_mut::<isize>()
];
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::<AnyObject>(),
wrap: false,
inSpellDocumentWithTag: 0isize,
wordCount: std::ptr::null_mut::<isize>()
];
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::<AnyObject>(),
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;
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<String>,
language: String,
has_cli_tool: bool,
}
impl LinuxSpeller {
pub fn new(lang_tag: &str) -> Option<Self> {
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;
}
}
// Only hunspell is ever invoked (`check_via_cli`), so only it counts.
let has_cli = is_command_in_path("hunspell");
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<String>) {
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);
}
}
// Not in the wordlist and no CLI answer: misspelled when there is
// a wordlist to judge by, unjudged (accepted) when there is none.
(self.words.is_empty(), Vec::new())
}
}
fn load_wordlist(path: &Path, words: &mut HashSet<String>) -> 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<String>)> {
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;
}
File diff suppressed because it is too large. Load diff