FU.5: craft-proof id, ru — the cased-letter rule for words in capitals (craft-libs#485): a caseless part is no abbreviation; shared in_capitals / capitals_part

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
This commit is contained in:
Martin BallandClaude Opus 5.5 committed 2026-11-02 01:18:42 +00:00
1 parent 86345ebcf5
commit 1f345ea809
4 files changed
+47 -6

No files matched your search

+14 -1
View File
@@ -76,7 +76,7 @@ fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
|| w.split('-').all(|p| {
p.chars().count() <= 1
|| CLITICS.iter().any(|c| p.eq_ignore_ascii_case(c))
|| (opts.ignore_uppercase && p.chars().all(|c| !c.is_lowercase()) && p.chars().count() <= 6)
|| (opts.ignore_uppercase && crate::spelling::capitals_part(p, 6))
|| known(d, p)
});
}
@@ -141,6 +141,19 @@ mod tests {
}
}
/// Review follow-up (craft-libs#485's cased-letter rule): a part in a caseless script is no
/// abbreviation, so `buku-日本` is checked part by part; a part in capitals or without letters
/// still is one (`KTP-nya`, `COVID-19` with numbers checked).
#[test]
fn caseless_parts_are_not_abbreviations() {
test_dict::install();
let d = dictionary();
let defaults = crate::Options::default();
assert!(!check(&d, "buku-日本", &defaults) && !check(&d, "buku-ไทย", &defaults));
let numbers = crate::Options { ignore_numbers: false, ..defaults };
assert!(check(&d, "KTP-nya", &defaults) && check(&d, "buku-19", &numbers));
}
#[test]
fn forbidden_words_are_rejected_in_every_case() {
test_dict::install();
+1 -1
View File
@@ -669,7 +669,7 @@ pub(crate) fn accepted_by_options(word: &str, opts: &Options) -> bool {
|| !w.chars().any(char::is_alphabetic)
|| (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit()))
// Only a word with a cased letter is in capitals: a Hebrew or Thai word has none.
|| (opts.ignore_uppercase && w.chars().any(char::is_uppercase) && w.chars().all(|c| !c.is_lowercase()))
|| (opts.ignore_uppercase && spelling::in_capitals(w))
// The whole word: the languages split a hyphenated word into its parts, and an added
// `Unigranrio-RJ` is none of them (#2082).
|| in_user_dictionary(&w.to_lowercase())
+4 -2
View File
@@ -105,7 +105,7 @@ fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
if n <= 1 || (opts.ignore_numbers && w.chars().any(char::is_numeric)) || !w.chars().any(char::is_alphabetic) {
return true;
}
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) && n <= ABBREVIATION_LETTERS {
if opts.ignore_uppercase && crate::spelling::in_capitals(w) && n <= ABBREVIATION_LETTERS {
return true;
}
// Camel case (`ВКонтакте`, `МирКрасок`): a brand or an identifier, not a dictionary word.
@@ -333,7 +333,7 @@ fn hyphenated(d: &AffixDict, w: &str, opts: &crate::Options) -> bool {
let last = parts.len() - 1;
parts.iter().enumerate().all(|(i, p)| {
// An abbreviation part (`ИИ-консультант`, `SMS-рассылка`) or a known word.
if known(d, p) || (opts.ignore_uppercase && p.chars().all(|c| !c.is_lowercase()) && p.chars().count() <= ABBREVIATION_LETTERS) {
if known(d, p) || (opts.ignore_uppercase && crate::spelling::capitals_part(p, ABBREVIATION_LETTERS)) {
return true;
}
// A first part with a linking vowel: the stem (`северо` → `север`) or its adjective
@@ -485,5 +485,7 @@ mod tests {
assert!(!crate::is_correct_in_with("м²", lang, &check_digits), "a superscript is a number too");
assert!(crate::is_correct_in_with("2026", lang, &check_digits), "numbers alone stay correct");
assert!(is_correct("ПРЕВЕТ"), "direct checks keep the defaults");
// The cased-letter rule (craft-libs#485): capitals and letterless parts are abbreviations.
assert!(crate::is_correct_in_with("ИИ-2", lang, &check_digits) && crate::is_correct_in_with("ТУ-154М", lang, &check_digits));
}
}
+28 -2
View File
@@ -32,17 +32,43 @@ pub(crate) fn word_to_check_in<'a>(d: &AffixDict, word: &'a str, opts: &Options)
if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) {
return None;
}
// Only a word with a cased letter can be in capitals: Hebrew, Arabic, Thai… words have none.
if opts.ignore_uppercase && w.chars().any(char::is_uppercase) && w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
if opts.ignore_uppercase && in_capitals(w) && n <= 6 {
return None;
}
Some(w)
}
/// Is `w` in capitals: a cased letter and no lower-case one? Only a word with a cased letter can
/// be: Hebrew, Arabic, Thai, Chinese… words have none, so they are never taken for acronyms.
pub(crate) fn in_capitals(w: &str) -> bool {
w.chars().any(char::is_uppercase) && w.chars().all(|c| !c.is_lowercase())
}
/// Is `part` of a hyphenated word an abbreviation Ignore words in UPPERCASE skips: at most `max`
/// characters, [`in_capitals`] or without letters (`SMS` in `SMS-ка`, `19` in `COVID-19`)? A
/// part in a caseless script is no abbreviation.
pub(crate) fn capitals_part(part: &str, max: usize) -> bool {
part.chars().count() <= max && (in_capitals(part) || !part.chars().any(char::is_alphabetic))
}
#[cfg(test)]
mod tests {
use super::*;
/// craft-libs#485's cased-letter rule, shared by every language: a word or part is in
/// capitals only with a cased letter; a part without letters counts as an abbreviation.
#[test]
fn capitals_need_a_cased_letter() {
for w in ["PKO", "SMS", "ÉTÉ", "ВКП", "B2B"] {
assert!(in_capitals(w) && capitals_part(w, 6), "{w}");
}
for w in ["日本", "שלום", "ไทย", "Pko", "pko", "123", ""] {
assert!(!in_capitals(w), "{w}");
}
assert!(capitals_part("19", 6) && capitals_part("", 6));
assert!(!capitals_part("日本", 6) && !capitals_part("ไทย", 6) && !capitals_part("ABCDEFG", 6));
}
#[test]
fn words_needing_no_lookup() {
let d = AffixDict::from_hunspell("", "1\nkot\n", &[]).unwrap();