diff --git a/crates/cc-app/src/fixgate.rs b/crates/cc-app/src/fixgate.rs index 0103062..7972bd9 100644 --- a/crates/cc-app/src/fixgate.rs +++ b/crates/cc-app/src/fixgate.rs @@ -4,6 +4,8 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; +use cc_correct::Severity; + /// Two undone fixes this close together in one window trip the breaker. const UNDO_WINDOW: Duration = Duration::from_secs(15); const UNDO_LIMIT: usize = 2; @@ -17,10 +19,56 @@ pub fn closes_word(c: char) -> bool { c.is_whitespace() || matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '"') } -/// Why `original` -> `replacement` must not be typed, if it must not. `whole_word`: the -/// shadow buffer saw where `original` starts, so the deletion cannot reach into an unseen -/// part of the word. `is_word` says whether a token is spelled right. -pub fn reject(original: &str, replacement: &str, whole_word: bool, is_word: impl Fn(&str) -> bool) -> Option<&'static str> { +/// Whether a candidate may be typed without asking at auto-fix `level`. Bold (S2) adds only +/// doubled-letter spelling fixes, which S0 refuses on capitalised words because those look +/// like names (`Outllook` -> `Outlook`). +pub fn eligible(severity: &Severity, rule: &str, original: &str, replacement: &str, level: &Severity) -> bool { + match severity { + Severity::S0 | Severity::S1 => severity <= level, + Severity::S2 => { + *level >= Severity::S2 && rule == "spell" && cc_correct::is_doubled_letter_typo(original, replacement) + } + Severity::S3 => false, + } +} + +/// `s` without the punctuation around it: `(teh` is `teh`, `reviews,` is `reviews`. +pub fn word_core(s: &str) -> &str { + s.trim_matches(|c: char| !c.is_alphanumeric()) +} + +/// The characters before and after the letters and digits of `s`. +fn outer_punctuation(s: &str) -> (&str, &str) { + let not_alnum = |c: char| !c.is_alphanumeric(); + let lead = &s[..s.len() - s.trim_start_matches(not_alnum).len()]; + let trail = &s[s.trim_end_matches(not_alnum).len()..]; + (lead, trail) +} + +/// Why `original` -> `replacement` must not be typed, if it must not. A fix replaces one +/// word with one word and keeps the punctuation around it; only an autocorrect entry +/// (`expands`) may stand for several words (`alot` -> `a lot`). `whole_word`: the shadow +/// buffer saw where `original` starts, so the deletion cannot reach into an unseen part of +/// the word. `is_word` says whether a token is spelled right. +pub fn reject( + original: &str, + replacement: &str, + whole_word: bool, + expands: bool, + is_word: impl Fn(&str) -> bool, +) -> Option<&'static str> { + if original.trim().is_empty() || original.chars().any(char::is_whitespace) { + return Some("changes more than one word"); + } + if replacement.trim().is_empty() { + return Some("would delete the word"); + } + if replacement.chars().any(char::is_whitespace) && !expands { + return Some("would split the word"); + } + if outer_punctuation(original) != outer_punctuation(replacement) { + return Some("would change the punctuation around the word"); + } if !whole_word { return Some("start of the word not seen"); } @@ -112,13 +160,126 @@ mod tests { #[test] fn reject_explains_unsafe_fixes() { - assert_eq!(reject("teh", "the", true, known), None); - assert_eq!(reject("alot", "a lot", true, known), None); - assert_eq!(reject("erge", "edge", false, known), Some("start of the word not seen")); - assert_eq!(reject("doesn", "does't", true, known), Some("result is not a known word")); - assert_eq!(reject("ne", "NE", true, |_| true), Some("would turn a word into capitals")); - assert_eq!(reject("i", "I", true, known), None); - assert_eq!(reject("Teh", "The", true, known), None); + assert_eq!(reject("teh", "the", true, false, known), None); + assert_eq!(reject("alot", "a lot", true, true, known), None); + assert_eq!(reject("alot", "a lot", true, false, known), Some("would split the word")); + assert_eq!(reject("Every one", "Everyone", true, false, |_| true), Some("changes more than one word")); + assert_eq!(reject("the the", "the", true, true, known), Some("changes more than one word")); + assert_eq!(reject("teh", "", true, true, known), Some("would delete the word")); + assert_eq!( + reject("reeviews,", "reviews", true, true, |_| true), + Some("would change the punctuation around the word") + ); + assert_eq!(reject("(teh", "(the", true, false, known), None); + assert_eq!(reject("erge", "edge", false, false, known), Some("start of the word not seen")); + assert_eq!(reject("doesn", "does't", true, false, known), Some("result is not a known word")); + assert_eq!(reject("ne", "NE", true, false, |_| true), Some("would turn a word into capitals")); + assert_eq!(reject("i", "I", true, false, known), None); + assert_eq!(reject("Teh", "The", true, false, known), None); + } + + #[test] + fn bold_adds_only_doubled_letter_spelling_fixes() { + use Severity::*; + assert!(eligible(&S0, "spell", "thhe", "the", &S0)); + assert!(!eligible(&S1, "sentence_capitalization", "this", "This", &S0)); + assert!(eligible(&S1, "sentence_capitalization", "this", "This", &S1)); + assert!(eligible(&S2, "spell", "Outllook", "Outlook", &S2)); + assert!(!eligible(&S2, "spell", "Outllook", "Outlook", &S1)); + assert!(!eligible(&S2, "spell", "pc", "pd", &S2)); + assert!(!eligible(&S2, "compound_nouns", "quick start", "quickstart", &S2)); + assert!(!eligible(&S3, "repeated_words", "the the", "the", &S2)); + } + + #[test] + fn word_core_drops_surrounding_punctuation_only() { + assert_eq!(word_core("(teh"), "teh"); + assert_eq!(word_core("reeviews,"), "reeviews"); + assert_eq!(word_core("don't"), "don't"); + assert_eq!(word_core("..."), ""); + } + + fn checker() -> &'static cc_correct::Checker { + static CHECKER: std::sync::OnceLock = std::sync::OnceLock::new(); + CHECKER.get_or_init(cc_correct::Checker::new) + } + + /// What `on_word_boundary` would type after each word of `sentence` at `level`, as + /// `(text so far, original, replacement)`. + fn auto_fixes(sentence: &str, level: &Severity) -> Vec<(String, String, String)> { + let dict = cc_correct::PersonalDictionary::default(); + let mut typed = String::new(); + let mut out = Vec::new(); + for word in sentence.split(' ') { + if !typed.is_empty() { + typed.push(' '); + } + typed.push_str(word); + let n = typed.chars().count(); + for c in checker().check(&typed, &dict) { + let at_caret = c.end == n && typed.ends_with(&c.original); + if at_caret + && eligible(&c.severity, &c.rule, &c.original, &c.replacement, level) + && reject(&c.original, &c.replacement, true, false, |t| checker().is_word(t, &dict)).is_none() + { + out.push((typed.clone(), c.original, c.replacement)); + } + } + } + out + } + + /// Sentences for which Harper offers to join, split or delete words. + const JOIN_BAIT: &[&str] = &[ + "Every one of them came to the quick start meeting", + "We should check out the new web site some time", + "They had had enough and I know that that is true", + "It is a good idea and also a cheap one", + "The more friendly option is more better", + "He should of known better alot of times", + "This is a test of the the system", + "Please make sure to double check the work", + "The data base is on line now", + "The kids played out side all day long", + "Any one of these will do and some one who knows can help", + "Some time ago some times I would go every where", + "Every thing is fine with out a doubt", + "Where as the old one failed there fore we left", + "How ever you do it what ever happens is fine", + "I need to up date the over load setting", + "Our team is under way with the work", + "It was a long while ago and we waited a while", + "She is in deed right and in fact over joyed", + "The book shelf and the note book are on the table", + "I sent an e mail to my class mate yesterday", + "We had a work shop on Monday and a lunch break", + ]; + + #[test] + fn no_auto_fix_joins_splits_or_deletes_words() { + for level in [Severity::S0, Severity::S1, Severity::S2] { + for sentence in JOIN_BAIT { + for (typed, original, replacement) in auto_fixes(sentence, &level) { + assert!( + original.split_whitespace().count() == 1 && replacement.split_whitespace().count() == 1, + "{level:?} after {typed:?}: {original:?} -> {replacement:?}" + ); + } + } + } + } + + #[test] + fn typos_are_still_fixed_at_their_level() { + let fixes = |s: &str, level: Severity| -> Vec<(String, String)> { + auto_fixes(s, &level).into_iter().map(|(_, o, r)| (o, r)).collect() + }; + let pair = |o: &str, r: &str| (o.to_string(), r.to_string()); + assert_eq!(fixes("I want thhe cat", Severity::S0), vec![pair("thhe", "the")]); + assert_eq!(fixes("I said soo", Severity::S0), vec![pair("soo", "so")]); + assert!(fixes("We use Outllook", Severity::S1).is_empty()); + assert_eq!(fixes("We use Outllook", Severity::S2), vec![pair("Outllook", "Outlook")]); + assert!(fixes("My name is Dimitri", Severity::S2).is_empty()); } #[test] diff --git a/crates/cc-app/src/main.rs b/crates/cc-app/src/main.rs index 99f10ef..9f77264 100644 --- a/crates/cc-app/src/main.rs +++ b/crates/cc-app/src/main.rs @@ -565,9 +565,10 @@ impl App { } } - /// Runs after the word before `boundary` is closed. Auto-applies one S0/S1 candidate that - /// spans exactly that word; anything else is left alone. Never runs after Enter, because - /// in chat fields the text has already been sent. + /// Runs after the word before `boundary` is closed. Auto-applies one candidate that the + /// auto-fix level allows (`fixgate::eligible`) and that replaces exactly that word; + /// anything else is left alone. Never runs after Enter, because in chat fields the text + /// has already been sent. fn on_word_boundary(&mut self, boundary: char) { let Some(level) = self.auto_level.clone() else { return }; if self.breaker.paused(self.last_hwnd, Instant::now()) { @@ -591,11 +592,15 @@ impl App { } let t0 = Instant::now(); let n_chars = sentence.chars().count(); - let candidate = match self.auto.lookup(&word) { + // The autocorrect list holds bare words: `(teh` looks up `teh`. A word with + // punctuation after it was already looked up when that punctuation was typed. + let core = word.trim_start_matches(|c: char| !c.is_alphanumeric()); + let listed = if core.ends_with(char::is_alphanumeric) { self.auto.lookup(core) } else { None }; + let candidate = match listed { Some(fix) => Some(cc_correct::Candidate { - start: n_chars - word.chars().count(), + start: n_chars - core.chars().count(), end: n_chars, - original: word.clone(), + original: core.to_string(), replacement: fix, rule: "autocorrect".into(), severity: Severity::S0, @@ -607,7 +612,7 @@ impl App { .into_iter() .find(|c| { c.end == n_chars - && c.severity <= level + && fixgate::eligible(&c.severity, &c.rule, &c.original, &c.replacement, &level) && sentence.ends_with(&c.original) && !self.demoted.contains(&c.rule) }), @@ -619,9 +624,10 @@ impl App { // The fix deletes `original` back from the caret. Unless the shadow saw what precedes // it, that may be the tail of a longer word, and the result would be a mangled word. let whole_word = body_len > c.original.len() || self.shadow.anchored(); - let taught = c.rule == "autocorrect" && self.auto.is_user_entry(&word); + let listed = c.rule == "autocorrect"; + let taught = listed && self.auto.is_user_entry(&c.original); let is_word = |token: &str| taught || self.checker.is_word(token, &self.dict); - if let Some(why) = fixgate::reject(&c.original, &c.replacement, whole_word, is_word) { + if let Some(why) = fixgate::reject(&c.original, &c.replacement, whole_word, listed, is_word) { log::info!("fix {:?} -> {:?} [{}] not applied: {why}", c.original, c.replacement, c.rule); self.prev_boundary = Some((word, self.shadow.text().chars().count())); return; @@ -681,21 +687,23 @@ impl App { /// pair becomes an autocorrect entry: that is how the list learns your own typos. fn learn_self_correction(&mut self, word: &str) { let Some((prev, prev_len)) = self.prev_boundary.take() else { return }; + // Punctuation typed around a word is not part of the typo. + let (prev, word) = (fixgate::word_core(&prev), fixgate::word_core(word)); let now_len = self.shadow.text().chars().count(); if now_len > prev_len || prev == word || prev.chars().count() < 3 || word.chars().count() < 2 { return; } - if cc_correct::is_code_like(&prev) || cc_correct::is_code_like(word) || !self.checker.is_word(word, &self.dict) { + if cc_correct::is_code_like(prev) || cc_correct::is_code_like(word) || !self.checker.is_word(word, &self.dict) { return; } if cc_correct::edit_distance(&prev.to_lowercase(), &word.to_lowercase()) > 2 { return; } - let prev_is_unknown = self.checker.check(&prev, &self.dict).iter().any(|c| c.rule == "spell"); + let prev_is_unknown = self.checker.check(prev, &self.dict).iter().any(|c| c.rule == "spell"); if !prev_is_unknown { return; } - self.auto.add(&prev, word); + self.auto.add(prev, word); if let Err(e) = self.auto.save(&self.auto_path) { log::warn!("autocorrect save failed: {e}"); } @@ -1410,6 +1418,12 @@ fn start() -> anyhow::Result<()> { let ui = UiServer::start(&profile.join("events.db"), &profile, ui_state.clone(), ui_tx).context("ui server")?; let windows = Windows::new().context("ui windows")?; log::info!("dashboard: {}", ui.url("dashboard")); + let auto = AutocorrectList::load(&auto_path); + if auto.file_is_stale() { + if let Err(e) = auto.save(&auto_path) { + log::warn!("autocorrect save failed: {e}"); + } + } let long_ago = Instant::now() - Duration::from_secs(24 * 3600); let mut app = App { store, @@ -1446,7 +1460,7 @@ fn start() -> anyhow::Result<()> { last_status: long_ago, checker: Checker::new(), dict: PersonalDictionary::load(&dict_path), - auto: AutocorrectList::load(&auto_path), + auto, auto_path, prev_boundary: None, dict_path, diff --git a/crates/cc-correct/src/autocorrect.rs b/crates/cc-correct/src/autocorrect.rs index 4ecb401..319a3d1 100644 --- a/crates/cc-correct/src/autocorrect.rs +++ b/crates/cc-correct/src/autocorrect.rs @@ -13,30 +13,65 @@ const SEED: &str = include_str!("autocorrect_seed.txt"); pub struct AutocorrectList { seed: HashMap, user: HashMap, + /// Loading the user file changed or dropped an entry, so the file is out of date. + stale_file: bool, } -fn parse(text: &str) -> HashMap { - text.lines() - .filter_map(|l| { - let l = l.trim(); - if l.is_empty() || l.starts_with('#') { - return None; +/// `typo` split into the punctuation before its letters, the letters, and the punctuation +/// after them. None when it has no letters or digits. +fn split_punctuation(typo: &str) -> Option<(&str, &str, &str)> { + let start = typo.find(char::is_alphanumeric)?; + let (last, c) = typo.char_indices().rev().find(|(_, c)| c.is_alphanumeric())?; + let end = last + c.len_utf8(); + Some((&typo[..start], &typo[start..end], &typo[end..])) +} + +/// A `typo -> fix` pair keyed by the bare typo, with the punctuation around the typo also +/// taken off the fix: a learned `reeviews, -> reviews` would otherwise delete the comma. +fn normalize(typo: &str, fix: &str) -> Option<(String, String)> { + let (lead, core, trail) = split_punctuation(typo.trim())?; + let fix = fix.trim(); + let fix = fix.strip_prefix(lead).unwrap_or(fix); + let fix = fix.strip_suffix(trail).unwrap_or(fix).trim(); + let key = core.to_lowercase(); + (!fix.is_empty() && key != fix.to_lowercase()).then(|| (key, fix.to_string())) +} + +/// The entries of `text`, and whether normalising changed or dropped any of them. +fn parse(text: &str) -> (HashMap, bool) { + let mut entries = HashMap::new(); + let mut changed = false; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((typo, fix)) = line.split_once("->") else { continue }; + match normalize(typo, fix) { + Some((key, clean)) => { + changed |= key != typo.trim().to_lowercase() || clean != fix.trim(); + entries.insert(key, clean); } - let (k, v) = l.split_once("->")?; - Some((k.trim().to_lowercase(), v.trim().to_string())) - }) - .filter(|(k, v)| !k.is_empty() && !v.is_empty() && k != &v.to_lowercase()) - .collect() + None => changed = true, + } + } + (entries, changed) } impl AutocorrectList { /// Built-in seed plus `path` (`typo -> fix` per line); a missing file is fine. pub fn load(path: &Path) -> AutocorrectList { - let user = std::fs::read_to_string(path).map(|t| parse(&t)).unwrap_or_default(); - AutocorrectList { seed: parse(SEED), user } + let (user, stale_file) = std::fs::read_to_string(path).map(|t| parse(&t)).unwrap_or_default(); + AutocorrectList { seed: parse(SEED).0, user, stale_file } + } + + /// The file `load` read had entries it had to clean up; `save` brings it up to date. + pub fn file_is_stale(&self) -> bool { + self.stale_file } - /// The fix for `word`, with the word's capitalisation carried over. User entries win. + /// The fix for `word` (a bare word, no punctuation around it), with the word's + /// capitalisation carried over. User entries win. pub fn lookup(&self, word: &str) -> Option { let key = word.to_lowercase(); let fix = self.user.get(&key).or_else(|| self.seed.get(&key))?; @@ -49,12 +84,15 @@ impl AutocorrectList { } pub fn add(&mut self, typo: &str, fix: &str) { - self.user.insert(typo.to_lowercase(), fix.to_string()); + if let Some((key, fix)) = normalize(typo, fix) { + self.user.insert(key, fix); + } } /// Removes a user entry and shadows a seed entry so it never fires again. pub fn remove(&mut self, typo: &str) { - let key = typo.to_lowercase(); + let Some((_, core, _)) = split_punctuation(typo.trim()) else { return }; + let key = core.to_lowercase(); self.user.remove(&key); self.seed.remove(&key); } @@ -114,4 +152,24 @@ mod tests { assert_eq!(again.lookup("teh"), None); std::fs::remove_dir_all(dir).ok(); } + + #[test] + fn punctuation_around_a_learned_typo_is_dropped_from_both_sides() { + let dir = std::env::temp_dir().join(format!("cc-auto-punct-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("autocorrect.txt"); + std::fs::write(&path, "(iof -> (if\nreeviews, -> reviews\nomw -> on my way!\n").unwrap(); + let mut list = AutocorrectList::load(&path); + assert!(list.file_is_stale()); + assert_eq!(list.lookup("iof").as_deref(), Some("if")); + assert_eq!(list.lookup("reeviews").as_deref(), Some("reviews")); + assert_eq!(list.lookup("omw").as_deref(), Some("on my way!")); + list.add("\"Wrod,", "\"word,"); + assert_eq!(list.lookup("wrod").as_deref(), Some("word")); + list.save(&path).unwrap(); + assert!(!AutocorrectList::load(&path).file_is_stale()); + list.remove("reeviews,"); + assert_eq!(list.lookup("reeviews"), None); + std::fs::remove_dir_all(dir).ok(); + } } diff --git a/crates/cc-correct/src/lib.rs b/crates/cc-correct/src/lib.rs index 09337d2..8415fb6 100644 --- a/crates/cc-correct/src/lib.rs +++ b/crates/cc-correct/src/lib.rs @@ -6,7 +6,9 @@ //! * `S0`: a typo with one obvious fix (auto-apply). //! * `S1`: grammar / punctuation / capitalisation with one clear fix (auto-apply). //! * `S2`: ambiguous (several fixes, or a distant spelling match). Suggest only. -//! * `S3`: never returned here. Lints without a replacement are dropped. +//! * `S3`: style rewrites, and edits across several words (`Every one` -> `Everyone`, +//! `had had` -> `had`), which are right or wrong depending on words not typed yet. +//! Never auto-applied. Lints without a replacement are dropped. //! //! Harper's `SpellCheck` always offers up to three suggestions, ordered by an edit-distance //! heuristic that is blind to typing mistakes (`hoow` -> `hood`, `easty` -> `east`, @@ -35,6 +37,24 @@ pub fn edit_distance(a: &str, b: &str) -> usize { osa_distance(&a, &b) } +/// Whether `typed` is `fix` with one or more letters pressed twice in a row (`thhe` for +/// `the`, `Outllook` for `Outlook`). Case is ignored. +pub fn is_doubled_letter_typo(typed: &str, fix: &str) -> bool { + let typed: Vec = typed.chars().map(lc).collect(); + let fix: Vec = fix.chars().map(lc).collect(); + let (mut j, mut extra) = (0, 0); + for (i, &c) in typed.iter().enumerate() { + if j < fix.len() && c == fix[j] { + j += 1; + } else if i > 0 && c == typed[i - 1] && c.is_alphabetic() { + extra += 1; + } else { + return false; + } + } + j == fix.len() && extra > 0 +} + /// How sure the checker is that a change is safe to apply. Lower is more automatic. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum Severity { @@ -237,7 +257,7 @@ impl Checker { let rule = rule_id(&key); for lint in lints { match self.map_lint(&key, &rule, &lint, &source, dictionary) { - Ok(pair) => mapped.push(pair), + Ok(entry) => mapped.push(entry), Err(dropped) => out.dropped.push(dropped), } } @@ -247,7 +267,8 @@ impl Checker { out } - /// Map one Harper lint. `Ok((candidate, is_spelling))` or `Err(dropped)`. + /// Map one Harper lint. `Ok((candidate, is_spelling, never_auto))` or `Err(dropped)`; + /// a `never_auto` candidate becomes S3 once it has won its span (see `resolve_overlaps`). fn map_lint( &self, key: &str, @@ -255,7 +276,7 @@ impl Checker { lint: &Lint, source: &[char], dictionary: &PersonalDictionary, - ) -> Result<(Candidate, bool), Dropped> { + ) -> Result<(Candidate, bool, bool), Dropped> { let (start, end) = (lint.span.start, lint.span.end); let original: String = if start < end && end <= source.len() { source[start..end].iter().collect() @@ -300,9 +321,10 @@ impl Checker { } let is_spelling = key == "SpellCheck" || lint.lint_kind == LintKind::Spelling; + let style = !is_spelling && style_like(&lint.lint_kind); let (replacement, mut severity, mut confidence) = if is_spelling { self.classify_spelling(&original, &texts) - } else if texts.len() == 1 && !style_like(&lint.lint_kind) && key != "SplitWords" { + } else if texts.len() == 1 && !style && key != "SplitWords" { (texts[0].clone(), Severity::S1, 0.9) } else { (texts[0].clone(), Severity::S2, 0.5) @@ -319,6 +341,18 @@ impl Checker { if replacement == original { return Err(drop("replacement_equals_original")); } + // Whether an edit across several words is right depends on the words after it: + // `Every one of them` and `had had` are correct English. One that changes a single + // word of the span is narrowed to that word (`to loose` -> `to lose` is `loose`). + let (start, end, original, replacement, across_words) = if original.split_whitespace().count() < 2 { + (start, end, original, replacement, false) + } else if let Some((offset, from, to)) = changed_word(&original, &replacement) { + let start = start + offset; + (start, start + from.chars().count(), from, to, false) + } else { + (start, end, original, replacement, true) + }; + let never_auto = style || across_words; // Capitalisation rules find a name or acronym in Harper's dictionary that matches a // lowercase typo of a common word: `fromm` -> `Fromm`, `orr` -> `Orr`, `ne` -> `NE`. if severity == Severity::S1 @@ -327,7 +361,11 @@ impl Checker { { if let Some((fix, confidence)) = common_word_typo(&original) { let rule = "spell".to_string(); - return Ok((Candidate { start, end, original, replacement: fix, rule, severity: Severity::S0, confidence }, true)); + return Ok(( + Candidate { start, end, original, replacement: fix, rule, severity: Severity::S0, confidence }, + true, + never_auto, + )); } if near_common_word(&original) || is_shouty(&replacement) { severity = Severity::S2; @@ -345,6 +383,7 @@ impl Checker { confidence, }, is_spelling, + never_auto, )) } @@ -438,9 +477,13 @@ impl Checker { // unknown word is probably a name: only a very common word may replace it automatically. let dialect_variant = self.dict.contains_word(&orig); let name_risk = first_upper && !common_words().contains(best.text.as_str()); + let long_enough = orig.len() >= MIN_S0_LEN + || (orig.len() >= 3 + && is_doubled_letter_typo(original, &best.text) + && common_words().contains(best.text.as_str())); if !dialect_variant && !name_risk - && orig.len() >= MIN_S0_LEN + && long_enough && margin >= S0_MIN_MARGIN && confidence >= S0_MIN_CONFIDENCE { @@ -461,8 +504,9 @@ const POOL_SIZE: usize = 200; const CACHE_LIMIT: usize = 4096; /// S0 needs a Damerau-Levenshtein distance of at most this. const S0_MAX_DISTANCE: usize = 2; -/// S0 needs an original of at least this many chars. -const MIN_S0_LEN: usize = 3; +/// S0 needs an original of at least this many chars, unless it is a common word with a +/// doubled letter (`soo`). Shorter typos have several likely fixes: `har` is `her` or `has`. +const MIN_S0_LEN: usize = 4; /// S0 needs `1 - distance / len` of at least this. (Not in the brief; keeps 2 edits on a /// 4-letter word from being auto-applied.) const S0_MIN_CONFIDENCE: f32 = 0.6; @@ -675,12 +719,15 @@ fn near_common_word(word: &str) -> bool { /// one, with the confidence an S0 fix gets. fn common_word_typo(word: &str) -> Option<(String, f32)> { let len = word.chars().count(); - if len < MIN_S0_LEN || common_words().contains(word) { + if len < 3 || common_words().contains(word) { return None; } let mut scored = common_neighbours(word); scored.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(b.1))); let best = scored.first()?; + if len < MIN_S0_LEN && !is_doubled_letter_typo(word, best.1) { + return None; + } let margin = scored.get(1).map_or(f32::INFINITY, |second| second.0 - best.0); let confidence = 1.0 - 1.0 / len as f32; (margin >= S0_MIN_MARGIN && confidence >= S0_MIN_CONFIDENCE).then(|| (best.1.to_string(), confidence)) @@ -728,8 +775,37 @@ fn plausible_split(text: &str) -> bool { .all(|part| part.chars().count() >= 2 || matches!(part, "a" | "A" | "I" | "i")) } -/// Lint kinds that are about style, not correctness. Even with one suggestion they are -/// "suggest" (S2), never auto-apply: the architecture doc puts style rewrites in S3. +/// Words of `s` with their char offsets. +fn word_spans(s: &str) -> Vec<(usize, String)> { + let mut out: Vec<(usize, String)> = Vec::new(); + let mut at_word_start = true; + for (i, c) in s.chars().enumerate() { + if c.is_whitespace() { + at_word_start = true; + } else if at_word_start { + out.push((i, c.to_string())); + at_word_start = false; + } else if let Some(last) = out.last_mut() { + last.1.push(c); + } + } + out +} + +/// The only word `replacement` changes in `original`: its char offset in `original`, the +/// word, and what it becomes. None when the number of words differs or several change. +fn changed_word(original: &str, replacement: &str) -> Option<(usize, String, String)> { + let (from, to) = (word_spans(original), word_spans(replacement)); + if from.len() != to.len() { + return None; + } + let mut changed = from.into_iter().zip(to).filter(|(a, b)| a.1 != b.1); + let ((offset, word), (_, new)) = changed.next()?; + changed.next().is_none().then_some((offset, word, new)) +} + +/// Lint kinds that are about style, not correctness. They are S3 even with one suggestion: +/// the architecture doc puts style rewrites there. fn style_like(kind: &LintKind) -> bool { matches!( kind, @@ -822,15 +898,17 @@ fn overlaps(a: &Candidate, b: &Candidate) -> bool { /// capitalisation or a word-split guess) wins, because it knows things the spell checker does /// not (`alot` -> `a lot`, not `alto`). Otherwise the lowest severity wins, then spelling over /// other rules, then higher confidence. Losers are reported as dropped. -/// Input is `(candidate, is_spelling)`. -fn resolve_overlaps(mut mapped: Vec<(Candidate, bool)>, dropped: &mut Vec) -> Vec { +/// Input is `(candidate, is_spelling, never_auto)`. A `never_auto` winner becomes S3 only +/// after the spans are settled, so it still keeps its span from other rules: `that that` +/// would otherwise go to the S1 `that` -> `which`. +fn resolve_overlaps(mut mapped: Vec<(Candidate, bool, bool)>, dropped: &mut Vec) -> Vec { let specific = |c: &Candidate, is_spelling: bool| { !is_spelling && c.severity == Severity::S1 && c.rule != "split_words" && !c.rule.contains("capitalization") }; - mapped.sort_by(|(a, a_spell), (b, b_spell)| { + mapped.sort_by(|(a, a_spell, _), (b, b_spell, _)| { specific(b, *b_spell) .cmp(&specific(a, *a_spell)) .then(a.severity.cmp(&b.severity)) @@ -839,7 +917,7 @@ fn resolve_overlaps(mut mapped: Vec<(Candidate, bool)>, dropped: &mut Vec = Vec::new(); - for (cand, _) in mapped { + for (mut cand, _, never_auto) in mapped { if let Some(winner) = kept.iter().find(|k| overlaps(k, &cand)) { dropped.push(Dropped { harper_rule: cand.rule.clone(), @@ -851,6 +929,10 @@ fn resolve_overlaps(mut mapped: Vec<(Candidate, bool)>, dropped: &mut Vec {fix}"); + } + for (typed, fix) in [("teh", "the"), ("the", "the"), ("htmlr", "humor"), ("hoow", "hood"), ("ok", "k"), ("11", "1")] { + assert!(!is_doubled_letter_typo(typed, fix), "{typed} -> {fix}"); + } + } + + #[test] + fn short_typos_are_s0_only_with_a_doubled_letter() { + let dict = PersonalDictionary::default(); + for (text, typo) in [("I saw har", "har"), ("I want anb", "anb"), ("I said thw", "thw")] { + let cands = checker().check(text, &dict); + assert!( + cands.iter().filter(|c| c.original == typo).all(|c| c.severity >= Severity::S2), + "{text}: {cands:?}" + ); + } + for (text, typo, fix) in [("It was soo", "soo", "so"), ("I think wwe", "wwe", "we")] { + let cands = checker().check(text, &dict); + let c = cands.iter().find(|c| c.original == typo).unwrap_or_else(|| panic!("{text}: {cands:?}")); + assert_eq!((c.replacement.as_str(), &c.severity), (fix, &Severity::S0), "{text}"); + } + } + #[test] fn is_code_like_cases() { for w in ["camelCase", "snake_case", "C:\\x\\y", "https://a.b", "a@b.c", "v2", "HTTP"] { diff --git a/crates/cc-overlay/Cargo.toml b/crates/cc-overlay/Cargo.toml index f8f7383..43c2cc8 100644 --- a/crates/cc-overlay/Cargo.toml +++ b/crates/cc-overlay/Cargo.toml @@ -4,6 +4,12 @@ version.workspace = true edition.workspace = true license.workspace = true +# Named `overlay_demo` so that building several crates' examples in one cargo +# invocation does not collide with cc-window's `demo` example. +[[example]] +name = "overlay_demo" +path = "examples/demo.rs" + [dependencies] anyhow.workspace = true log.workspace = true diff --git a/crates/cc-overlay/examples/demo.rs b/crates/cc-overlay/examples/demo.rs index e2703cb..bce1ad8 100644 --- a/crates/cc-overlay/examples/demo.rs +++ b/crates/cc-overlay/examples/demo.rs @@ -2,7 +2,7 @@ //! 500 ms, with a small offset pattern so it visibly moves even if the mouse is still), //! then an underline fades out under the last position, then the overlay shuts down. //! -//! Run: `cargo run -p cc-overlay --example demo` +//! Run: `cargo run -p cc-overlay --example overlay_demo` use std::thread::sleep; use std::time::{Duration, Instant}; diff --git a/crates/cc-store/Cargo.toml b/crates/cc-store/Cargo.toml index 236d7e2..8f22a32 100644 --- a/crates/cc-store/Cargo.toml +++ b/crates/cc-store/Cargo.toml @@ -4,6 +4,12 @@ version.workspace = true edition.workspace = true license.workspace = true +# Named `store_dump` so that building several crates' examples in one cargo +# invocation does not collide with cc-hook's `dump` example. +[[example]] +name = "store_dump" +path = "examples/dump.rs" + [dependencies] anyhow.workspace = true log.workspace = true diff --git a/crates/cc-store/examples/dump.rs b/crates/cc-store/examples/dump.rs index b241c24..0ae6c1b 100644 --- a/crates/cc-store/examples/dump.rs +++ b/crates/cc-store/examples/dump.rs @@ -1,7 +1,7 @@ //! Print what the profile's `events.db` has learned so far. //! //! ```text -//! cargo run -p cc-store --example dump [path\to\events.db] +//! cargo run -p cc-store --example store_dump [path\to\events.db] //! ``` //! //! Defaults to `%LOCALAPPDATA%\CaseComplete\profiles\default\events.db`. diff --git a/crates/cc-tray/src/lib.rs b/crates/cc-tray/src/lib.rs index fa0b628..d74babd 100644 --- a/crates/cc-tray/src/lib.rs +++ b/crates/cc-tray/src/lib.rs @@ -89,7 +89,7 @@ const LEVELS: [(&str, &str, &str); 4] = [ ("cc-tray.level.off", "off", "Off"), ("cc-tray.level.s0", "S0", "Typos (S0)"), ("cc-tray.level.s1", "S1", "Typos + grammar (S1)"), - ("cc-tray.level.s2", "S2", "Also ambiguous (S2)"), + ("cc-tray.level.s2", "S2", "Also capitalised words (S2)"), ]; /// Models: (menu id, value reported in events, menu label). diff --git a/crates/cc-ui/Cargo.toml b/crates/cc-ui/Cargo.toml index 62f1eac..847fbab 100644 --- a/crates/cc-ui/Cargo.toml +++ b/crates/cc-ui/Cargo.toml @@ -5,6 +5,12 @@ edition.workspace = true license.workspace = true description = "Local web UI (flyout + dashboard) for the CaseComplete typing assistant" +# Named `ui_demo` so that building several crates' examples in one cargo +# invocation does not collide with cc-window's `demo` example. +[[example]] +name = "ui_demo" +path = "examples/demo.rs" + [dependencies] anyhow.workspace = true log.workspace = true diff --git a/crates/cc-ui/assets/app.js b/crates/cc-ui/assets/app.js index ef76267..2ca07f9 100644 --- a/crates/cc-ui/assets/app.js +++ b/crates/cc-ui/assets/app.js @@ -98,7 +98,7 @@ off: 'Nothing is changed for you.', S0: 'Obvious typos only.', S1: 'Typos and clear grammar slips.', - S2: 'Typos, grammar and style. Fixes more, so more get undone.', + S2: 'Typos and grammar, plus doubled letters in capitalised words such as Outllook.', }; function modelOf(st) { diff --git a/crates/cc-ui/examples/demo.rs b/crates/cc-ui/examples/demo.rs index c129bb5..fe621f4 100644 --- a/crates/cc-ui/examples/demo.rs +++ b/crates/cc-ui/examples/demo.rs @@ -2,7 +2,7 @@ //! (applying commands, rewriting the word files). Handy for looking at the pages: //! //! ```text -//! cargo run -p cc-ui --example demo -- [profile-dir] +//! cargo run -p cc-ui --example ui_demo -- [profile-dir] //! ``` //! //! Prints the flyout and dashboard addresses and serves until Ctrl-C or Quit.