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}")); + } }