From 130ac8d404e7d7533d41dbe8f37123e52eb870fa Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Sun, 1 Nov 2026 10:46:06 +0000 Subject: [PATCH] =?UTF-8?q?storytold/wordcraft#5000:=20review=20=E2=80=94?= =?UTF-8?q?=20a=20KEEPCASE=20word=20is=20suggested=20in=20its=20dictionary?= =?UTF-8?q?=20case,=20never=20re-cased=20into=20a=20flagged=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Kmm` with `km/K` suggested `Km`, which the checker flags; `Km` itself got no suggestion. A suggestion whose dictionary form is only correct through a KEEPCASE stem now keeps that case (`km`). Other stems are re-cased as before (`MIXCASX` → `MIXCASE`). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2 --- crates/proof/src/affix.rs | 6 ++++++ crates/proof/src/affix_suggest.rs | 21 +++++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index c966cf13..cb462121 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -941,6 +941,12 @@ impl AffixDict { self.check_except(w, self.no_suggest | self.substandard) } + /// Whether `w` is a word only through a `KEEPCASE` stem, so it is correct in this letter case + /// alone (`km` from `km/K`, which `Km` and `KM` are not). + pub(crate) fn case_locked(&self, w: &str) -> bool { + self.keepcase != Mask::EMPTY && self.contains(w) && !self.contains_except(&self.normalize(w), self.keepcase) + } + fn check_except(&self, w: &str, deny: Mask) -> bool { let w = self.normalize(w); if self.contains_except(&w, deny) { diff --git a/crates/proof/src/affix_suggest.rs b/crates/proof/src/affix_suggest.rs index 3d7cef64..1456f81c 100644 --- a/crates/proof/src/affix_suggest.rs +++ b/crates/proof/src/affix_suggest.rs @@ -297,11 +297,13 @@ pub(crate) fn suggest_in(d: &AffixDict, word: &str, max: usize, t: &Tuning) -> V found.sort_by_key(|(text, (cost, seq))| (*cost, diff_at(text), *seq)); let mut out: Vec = Vec::new(); for (text, _) in found { - let t = match case { + let recased = match case { Casing::Initial => capitalize(&text), Casing::Upper if n > 1 => text.to_uppercase(), - _ => text, + _ => text.clone(), }; + // A KEEPCASE word only exists in its dictionary case: `Km` would be flagged, so offer `km`. + let t = if recased != text && text.split(' ').any(|p| d.case_locked(p)) { text } else { recased }; if t != word && !out.contains(&t) { out.push(t); } @@ -521,6 +523,21 @@ mod tests { assert_eq!(weighted_distance(&c(""), &c("ab"), &related, 9), 4); } + /// A KEEPCASE word is offered in its dictionary case when the word's own case would be flagged + /// (`Kmm` → `km`, never `Km`), and a capitalised KEEPCASE word gets its dictionary form. + #[test] + fn keepcase_suggestions_are_not_recased_into_errors() { + let d = AffixDict::from_hunspell("KEEPCASE K\n", "2\nkm/K\npies\n", &[]).unwrap(); + assert!(!d.check("Km") && !d.check("KM")); + for (word, want) in [("Kmm", "km"), ("KMM", "km"), ("Km", "km")] { + let got = suggest_in(&d, word, 4, &Tuning::DEFAULT); + assert!(got.iter().all(|s| s.split(' ').all(|p| d.check(p))), "{word}: {got:?}"); + assert_eq!(got.first().map(String::as_str), Some(want), "{word}: {got:?}"); + } + assert_eq!(suggest_in(&d, "Pies", 4, &Tuning::DEFAULT), Vec::::new()); + assert_eq!(suggest_in(&d, "Piesx", 4, &Tuning::DEFAULT).first().map(String::as_str), Some("Pies")); + } + /// A language's tables come after its dictionary's, a TRY alphabet stands in for a missing TRY /// line, and a split rule decides which missing spaces are offered. #[test]