ravenrs_sheet/engine/cmd/
spelling.rs1use std::collections::HashSet;
5use std::sync::OnceLock;
6
7use crate::base::{RangeRef, Value};
8use serde_json::{Value as Json, json};
9
10use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param, cmd, has_doc, ok, str_param, target_range, target_sheet};
11
12pub fn specs() -> Vec<CommandSpec> {
13 vec![
14 cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{range?, sheet?} → misspelled words with cells and suggestions", has_doc, spelling),
15 cmd!(noundo "review.addToDictionary", "Add to Dictionary", [], None, "{word}", has_doc, add_word),
16 cmd!("review.changeSpelling", "Change", [], None, "{cell, word, to, all?: bool}", has_doc, change),
17 cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word} → related words (words sharing the stem in the dictionary)", has_doc, thesaurus),
18 ]
19}
20
21fn dictionary() -> &'static HashSet<String> {
22 static DICT: OnceLock<HashSet<String>> = OnceLock::new();
23 DICT.get_or_init(|| {
24 #[cfg(target_arch = "wasm32")]
25 {
26 HashSet::new()
27 }
28 #[cfg(not(target_arch = "wasm32"))]
29 {
30 let mut set = HashSet::new();
31 for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] {
32 if let Ok(text) = std::fs::read_to_string(path) {
33 for w in text.lines().take(2_000_000) {
34 set.insert(w.trim().to_lowercase());
35 }
36 break;
37 }
38 }
39 set
40 }
41 })
42}
43
44fn known(s: &Session, w: &str) -> bool {
45 let l = w.to_lowercase();
46 let dict = dictionary();
47 if dict.contains(&l) || s.prefs.user_dictionary.iter().any(|u| u.eq_ignore_ascii_case(w)) {
48 return true;
49 }
50 for suf in ["s", "es", "ed", "ing", "ly", "er", "ers", "'s"] {
52 if let Some(stem) = l.strip_suffix(suf)
53 && stem.len() > 2
54 && (dict.contains(stem) || dict.contains(&format!("{stem}e")))
55 {
56 return true;
57 }
58 }
59 if let Some(stem) = l.strip_suffix("ies")
60 && dict.contains(&format!("{stem}y"))
61 {
62 return true;
63 }
64 false
65}
66
67const MAX_SUGGEST_LEN: usize = 64;
70
71fn edits1(w: &str) -> Vec<String> {
72 if w.chars().count() > MAX_SUGGEST_LEN {
73 return Vec::new();
74 }
75 let chars: Vec<char> = w.chars().collect();
76 let mut out = Vec::new();
77 let letters = "abcdefghijklmnopqrstuvwxyz";
78 for i in 0..=chars.len() {
79 if i < chars.len() {
80 let mut d = chars.clone();
81 d.remove(i);
82 out.push(d.iter().collect());
83 if i + 1 < chars.len() {
84 let mut t = chars.clone();
85 t.swap(i, i + 1);
86 out.push(t.iter().collect());
87 }
88 for c in letters.chars() {
89 let mut r = chars.clone();
90 r[i] = c;
91 out.push(r.iter().collect());
92 }
93 }
94 for c in letters.chars() {
95 let mut ins = chars.clone();
96 ins.insert(i, c);
97 out.push(ins.iter().collect());
98 }
99 }
100 out
101}
102
103fn suggestions(w: &str) -> Vec<String> {
104 let dict = dictionary();
105 let l = w.to_lowercase();
106 let mut out: Vec<String> = edits1(&l).into_iter().filter(|c| dict.contains(c)).collect();
107 out.sort();
108 out.dedup();
109 out.truncate(6);
110 let cap = w.chars().next().is_some_and(char::is_uppercase);
112 out.into_iter()
113 .map(|s| {
114 if cap {
115 let mut c = s.chars();
116 c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or(s)
117 } else {
118 s
119 }
120 })
121 .collect()
122}
123
124fn spelling(s: &mut Session, p: &Json) -> Result<Json> {
125 if dictionary().is_empty() {
126 return Err(EngineError::Other("No dictionary is available on this system (GridCraft uses the system word list).".into()));
127 }
128 let si = target_sheet(s, p)?;
129 let d = s.doc()?;
130 let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
131 let scope = match str_param(p, "range") {
132 Some(_) => target_range(s, p)?,
133 None => sh.used_range().unwrap_or(RangeRef::cell(d.selection.active)),
134 };
135 let mut issues = Vec::new();
136 for (c, cell) in sh.cells.iter_range(scope) {
137 if cell.formula.is_some() {
138 continue;
139 }
140 let Value::Text(t) = &cell.value else { continue };
141 for w in t.split(|ch: char| !(ch.is_alphabetic() || ch == '\'')) {
142 let w = w.trim_matches('\'');
143 if w.chars().count() < 2 || w.chars().all(char::is_uppercase) || !w.is_ascii() {
144 continue;
145 }
146 if !known(s, w) {
147 issues.push(json!({"cell": c.a1(), "word": w, "suggestions": suggestions(w)}));
148 }
149 }
150 if issues.len() >= 500 {
151 break;
152 }
153 }
154 Ok(json!({"issues": issues, "done": issues.is_empty()}))
155}
156
157fn add_word(s: &mut Session, p: &Json) -> Result<Json> {
158 let w = str_param(p, "word").ok_or_else(|| bad("review.addToDictionary", "missing `word`"))?.to_string();
159 if !s.prefs.user_dictionary.iter().any(|u| u.eq_ignore_ascii_case(&w)) {
160 s.prefs.user_dictionary.push(w);
161 }
162 ok()
163}
164
165fn change(s: &mut Session, p: &Json) -> Result<Json> {
166 let word = str_param(p, "word").ok_or_else(|| bad("review.changeSpelling", "missing `word`"))?.to_string();
167 let to = str_param(p, "to").ok_or_else(|| bad("review.changeSpelling", "missing `to`"))?.to_string();
168 if bool_param(p, "all").unwrap_or(false) {
169 return s.execute("edit.replace", json!({"what": word, "with": to, "matchCase": true, "all": true}));
170 }
171 let c = cell_param(s, p, "cell")?.ok_or_else(|| bad("review.changeSpelling", "missing `cell`"))?;
172 let text = s.doc()?.wb.active().and_then(|sh| sh.cell(c)).map(|x| x.input_text()).unwrap_or_default();
173 s.execute("cell.set", json!({"cell": c.a1(), "input": text.replacen(&word, &to, 1)}))
174}
175
176fn thesaurus(_: &mut Session, p: &Json) -> Result<Json> {
177 let w = str_param(p, "word").unwrap_or("").to_lowercase();
178 if w.len() < 3 {
179 return Ok(json!([]));
180 }
181 let stem: String = w.chars().take(w.chars().count().saturating_sub(2).max(3)).collect();
182 let mut related: Vec<&String> = dictionary().iter().filter(|d| d.starts_with(&stem) && **d != w && d.len() <= w.len() + 4).collect();
183 related.sort();
184 related.truncate(20);
185 Ok(json!(related))
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn edits1_is_capped_for_very_long_words() {
194 assert_eq!(edits1("helo").len(), 54 * 4 + 25);
195 assert_eq!(edits1(&"a".repeat(MAX_SUGGEST_LEN)).len(), 54 * MAX_SUGGEST_LEN + 25);
196 assert!(edits1(&"a".repeat(MAX_SUGGEST_LEN + 1)).is_empty());
197 assert!(edits1(&"a".repeat(1_000_000)).is_empty());
198 }
199}