From 64bd43db1d4893ef014ce9f1bb07ed1842c6ebfc Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 2 Nov 2026 00:27:37 +0000 Subject: [PATCH] =?UTF-8?q?CP6.13:=20craft-libs#487=20review=20=E2=80=94?= =?UTF-8?q?=20a=20trailing=20combining=20mark=20stays=20on=20its=20word;?= =?UTF-8?q?=20Italian=20and=20Portuguese=20look=20a=20hyphenated=20word=20?= =?UTF-8?q?up=20whole=20before=20its=20parts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2 --- crates/proof/src/it/mod.rs | 11 ++++++++++- crates/proof/src/pt/mod.rs | 11 ++++++++++- crates/proof/src/spelling.rs | 13 ++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/proof/src/it/mod.rs b/crates/proof/src/it/mod.rs index ecc4aad6..a742eaa2 100644 --- a/crates/proof/src/it/mod.rs +++ b/crates/proof/src/it/mod.rs @@ -95,8 +95,9 @@ fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool { if TRUNCATIONS.contains(&lower.as_str()) || left_to_grammar(&lower) { return true; } + // The whole word first, as Hunspell does: a hyphenated entry or user word. if w.contains('-') { - return w.split('-').all(|p| p.chars().count() <= 1 || known(d, p)); + return known(d, w) || w.split('-').all(|p| p.chars().count() <= 1 || known(d, p)); } if let Some((head, tail)) = w.split_once(['\'', '’']) && PROCLITICS.contains(&head.to_lowercase().as_str()) @@ -139,6 +140,14 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// craft-libs#487 review: a hyphenated dictionary entry is a word even when its parts aren't. + #[test] + fn a_hyphenated_entry_is_looked_up_whole() { + let d = AffixDict::from_hunspell("SET UTF-8\n", "1\ntic-tac\n", &[]).unwrap(); + assert!(is_correct_with(&d, "tic-tac")); + assert!(!is_correct_with(&d, "tic-tacc")); + } + #[test] fn without_a_dictionary_nothing_is_flagged() { let none = AffixDict::default(); diff --git a/crates/proof/src/pt/mod.rs b/crates/proof/src/pt/mod.rs index 661bb998..00e80265 100644 --- a/crates/proof/src/pt/mod.rs +++ b/crates/proof/src/pt/mod.rs @@ -71,8 +71,9 @@ fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool { let Some(w) = crate::spelling::word_to_check(d, word, opts) else { return true; }; + // The whole word first, as Hunspell does: a hyphenated entry or user word. if w.contains('-') { - return w.split('-').all(|p| p.chars().count() <= 1 || known(d, p)); + return known(d, w) || w.split('-').all(|p| p.chars().count() <= 1 || known(d, p)); } known(d, w) } @@ -96,6 +97,14 @@ mod tests { assert!(!d.rep().is_empty() && !d.map().is_empty() && !d.try_chars().is_empty()); } + /// craft-libs#487 review: a hyphenated dictionary entry is a word even when its parts aren't. + #[test] + fn a_hyphenated_entry_is_looked_up_whole() { + let d = AffixDict::from_hunspell("SET UTF-8\n", "1\ntic-tac\n", &[]).unwrap(); + assert!(is_correct_with(&d, "tic-tac")); + assert!(!is_correct_with(&d, "tic-tacc")); + } + #[test] fn without_a_dictionary_nothing_is_flagged() { let none = AffixDict::default(); diff --git a/crates/proof/src/spelling.rs b/crates/proof/src/spelling.rs index aaf25956..c80f42fe 100644 --- a/crates/proof/src/spelling.rs +++ b/crates/proof/src/spelling.rs @@ -24,7 +24,9 @@ pub(crate) fn word_to_check_in<'a>(d: &AffixDict, word: &'a str, opts: &Options) if d.is_empty() { return None; } - let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’'); + // A trailing combining mark with no precomposed form (`x\u{338}`) is part of the word. + let edge = |c: char| !c.is_alphanumeric() && c != '\'' && c != '’'; + let w = word.trim_start_matches(edge).trim_end_matches(|c| edge(c) && !unicode_normalization::char::is_combining_mark(c)); let w = w.trim_matches(['\'', '’']); let n = w.chars().count(); if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) { @@ -56,4 +58,13 @@ mod tests { assert_eq!(word_to_check(&d, "B2B", &strict), Some("B2B")); assert_eq!(word_to_check(&d, "PRZEDSIĘBIORSTWO", &defaults), Some("PRZEDSIĘBIORSTWO"), "only short capitals are acronyms"); } + + /// craft-libs#487 review: a trailing combining mark stays on the word it marks. + #[test] + fn trailing_combining_marks_are_kept() { + let d = AffixDict::from_hunspell("", "1\nkot\n", &[]).unwrap(); + let defaults = Options::default(); + assert_eq!(word_to_check(&d, "kox\u{338}", &defaults), Some("kox\u{338}")); + assert_eq!(word_to_check(&d, "(kox\u{338}).", &defaults), Some("kox\u{338}")); + } }