mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-08 19:42:12 +00:00
storytold/wordcraft#3360: Spanish proofing code clean-up (no behaviour change)
- es/mod.rs: one variety() match gives the dictionary slot, log name and file names (variety_name() folded in); load_installed() destructures it - es/grammar.rs: the sentence-start rule for the missing ¿/¡ is one named function, ends_clause(), replacing is_boundary() and three closures rebuilt per mark; in_address() is shared; repeated_words() builds its helpers once instead of per word pair - es/suggest.rs reuses the Czech ranking's MAX_LETTERS instead of a copy Same tests pass unchanged; a temporary old-vs-new differential proptest (20,000+ generated inputs with URLs and punctuation) found identical output.
This commit is contained in:
1 parent
ecb4638980
commit
970bfcf7a8
4 files changed
+44
-44
No files matched your search
@@ -6,7 +6,8 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::affix::{AffixDict, Casing, capitalize, casing};
|
||||
|
||||
const MAX_LETTERS: usize = 40;
|
||||
/// Longest word suggestions are looked for, in letters (Spanish shares the bound).
|
||||
pub(crate) const MAX_LETTERS: usize = 40;
|
||||
const MAX_MAP_CHANGES: usize = 4;
|
||||
const MAX_MAP_VARIANTS: usize = 4096;
|
||||
/// Stems the scan of round 6 expands.
|
||||
|
||||
@@ -26,13 +26,13 @@ pub fn check(text: &str, lang: ProofLang) -> Vec<Issue> {
|
||||
}
|
||||
|
||||
fn repeated_words(text: &str, ws: &[(usize, usize)], lang: ProofLang, v: &mut Vec<Issue>) {
|
||||
// Letters and the combining marks of a decomposed word (`tambie\u{301}n`); a composed and a
|
||||
// decomposed spelling of one word are the same word.
|
||||
let letters = |w: &str| w.chars().all(|c| c.is_alphabetic() || unicode_normalization::char::is_combining_mark(c));
|
||||
let folded = |w: &str| w.nfc().collect::<String>().to_lowercase();
|
||||
for pair in ws.windows(2) {
|
||||
let [(a0, b0), (a1, b1)] = pair else { continue };
|
||||
let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue };
|
||||
// Letters and the combining marks of a decomposed word (`tambie\u{301}n`); a composed and
|
||||
// a decomposed spelling of one word are the same word.
|
||||
let letters = |w: &str| w.chars().all(|c| c.is_alphabetic() || unicode_normalization::char::is_combining_mark(c));
|
||||
let folded = |w: &str| w.nfc().collect::<String>().to_lowercase();
|
||||
if !gap.is_empty() && gap.chars().all(char::is_whitespace) && letters(w0) && folded(w0) == folded(w1) {
|
||||
v.push(issue(*a0, *b1, format!("Palabra repetida: «{w1}»"), vec![w0.to_string()], lang));
|
||||
}
|
||||
@@ -83,10 +83,22 @@ fn spaces(text: &str, lang: ProofLang, v: &mut Vec<Issue>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Characters a question or exclamation can't reach back past: sentence ends, clause breaks,
|
||||
/// line breaks, and the start of a quotation or parenthesis.
|
||||
fn is_boundary(c: char) -> bool {
|
||||
matches!(c, '.' | '?' | '!' | '…' | ':' | ';' | '\n' | '\r' | '\t' | '«' | '“' | '"' | '(' | '[' | '—')
|
||||
/// Does the character `b` at byte `j` end what comes before it, so a question or exclamation after
|
||||
/// it starts there? Sentence ends, clause breaks, line breaks and the start of a quotation or
|
||||
/// parenthesis do, but not punctuation inside a word (`3.5`, `10:30`) or inside an address
|
||||
/// (`example.com`, `https:`), except a mark ending an address (`…/? Vienes mañana?`).
|
||||
fn ends_clause(text: &str, addresses: &[(usize, usize)], j: usize, b: char) -> bool {
|
||||
let after = j + b.len_utf8();
|
||||
if in_address(addresses, j) {
|
||||
return matches!(b, '?' | '!' | '.') && addresses.iter().any(|&(_, e)| e == after);
|
||||
}
|
||||
let inside_word = matches!(b, '.' | ':' | ';') && text.get(after..).and_then(|r| r.chars().next()).is_some_and(char::is_alphanumeric);
|
||||
!inside_word && matches!(b, '.' | '?' | '!' | '…' | ':' | ';' | '\n' | '\r' | '\t' | '«' | '“' | '"' | '(' | '[' | '—')
|
||||
}
|
||||
|
||||
/// Is byte `j` inside one of the `addresses` ([`address_ranges`])?
|
||||
fn in_address(addresses: &[(usize, usize)], j: usize) -> bool {
|
||||
addresses.iter().any(|&(s, e)| s <= j && j < e)
|
||||
}
|
||||
|
||||
/// The byte ranges of the whitespace-delimited tokens of `text` that are web or e-mail addresses
|
||||
@@ -112,10 +124,6 @@ fn address_ranges(text: &str) -> Vec<(usize, usize)> {
|
||||
/// (`example.com`, `3.5`, `10:30`) a sentence's start.
|
||||
fn opening_marks(text: &str, lang: ProofLang, v: &mut Vec<Issue>) {
|
||||
let addresses = address_ranges(text);
|
||||
let in_address = |j: usize| addresses.iter().any(|&(s, e)| s <= j && j < e);
|
||||
// An address's own punctuation starts no sentence, except a mark that ends it
|
||||
// (`…/? Vienes mañana?`): that one isn't flagged, but whatever follows is a new sentence.
|
||||
let ends_address = |j: usize, b: char| addresses.iter().any(|&(_, e)| j + b.len_utf8() == e) && matches!(b, '?' | '!' | '.');
|
||||
for (i, c) in text.char_indices() {
|
||||
let (opener, message) = match c {
|
||||
'?' => ('¿', "Falta el signo de interrogación de apertura (¿)"),
|
||||
@@ -127,13 +135,14 @@ fn opening_marks(text: &str, lang: ProofLang, v: &mut Vec<Issue>) {
|
||||
let next = text.get(i + 1..).and_then(|r| r.chars().next());
|
||||
// `?!` and `!!!` are handled at their first mark; `(?)` stands alone; `?q=`, `a!b` and a
|
||||
// mark ending an address (`https://example.com/?`) aren't sentence ends.
|
||||
if matches!(prev, Some('?' | '!')) || (prev == Some('(') && next == Some(')')) || next.is_some_and(char::is_alphanumeric) || in_address(i) {
|
||||
if matches!(prev, Some('?' | '!'))
|
||||
|| (prev == Some('(') && next == Some(')'))
|
||||
|| next.is_some_and(char::is_alphanumeric)
|
||||
|| in_address(&addresses, i)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let within_word = |j: usize, b: char| text.get(j + b.len_utf8()..).and_then(|r| r.chars().next()).is_some_and(char::is_alphanumeric);
|
||||
let from = before.char_indices().rev().find(|&(j, b)| {
|
||||
matches!(b, '¿' | '¡') || ends_address(j, b) || (is_boundary(b) && !in_address(j) && !(matches!(b, '.' | ':' | ';') && within_word(j, b)))
|
||||
});
|
||||
let from = before.char_indices().rev().find(|&(j, b)| matches!(b, '¿' | '¡') || ends_clause(text, &addresses, j, b));
|
||||
if from.is_some_and(|(_, b)| matches!(b, '¿' | '¡')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
+15
-23
@@ -40,30 +40,21 @@ const LATIN_AMERICA: &[&str] = &["es_419", "es-419", "es_ANY", "es", "es_US", "e
|
||||
/// or generic one.
|
||||
const URUGUAY: &[&str] = &["es_UY", "es-UY", "es_AR", "es-AR", "es_419", "es-419", "es_ANY", "es"];
|
||||
|
||||
/// The slot of `lang`'s dictionary in [`DICTS`] and its file names; Spain for any other language.
|
||||
fn variety(lang: ProofLang) -> (usize, &'static [&'static str]) {
|
||||
/// `lang`'s variety: its slot in [`DICTS`], its English name (for the log) and its dictionary's
|
||||
/// file names; Spain for any other language.
|
||||
fn variety(lang: ProofLang) -> (usize, &'static str, &'static [&'static str]) {
|
||||
match lang {
|
||||
ProofLang::EsMx => (1, MEXICO),
|
||||
ProofLang::Es419 => (2, LATIN_AMERICA),
|
||||
ProofLang::EsUy => (3, URUGUAY),
|
||||
_ => (0, SPAIN),
|
||||
}
|
||||
}
|
||||
|
||||
/// The variety's English name, for the log.
|
||||
fn variety_name(lang: ProofLang) -> &'static str {
|
||||
match lang {
|
||||
ProofLang::EsMx => "Spanish (Mexico)",
|
||||
ProofLang::Es419 => "Spanish (Latin America)",
|
||||
ProofLang::EsUy => "Spanish (Uruguay)",
|
||||
_ => "Spanish (Spain)",
|
||||
ProofLang::EsMx => (1, "Spanish (Mexico)", MEXICO),
|
||||
ProofLang::Es419 => (2, "Spanish (Latin America)", LATIN_AMERICA),
|
||||
ProofLang::EsUy => (3, "Spanish (Uruguay)", URUGUAY),
|
||||
_ => (0, "Spanish (Spain)", SPAIN),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Hunspell name each variety's dictionary is known by (`es_ES`, `es_MX`, `es_419`,
|
||||
/// `es_UY`): the first of its file names, used for the status-bar hint and load errors.
|
||||
pub fn dictionary_name(lang: ProofLang) -> &'static str {
|
||||
variety(lang).1.first().copied().unwrap_or("es_ES")
|
||||
variety(lang).2.first().copied().unwrap_or("es_ES")
|
||||
}
|
||||
|
||||
/// The Spanish dictionary of `lang`'s variety: the installed one (read on first use), one given
|
||||
@@ -81,14 +72,15 @@ pub fn dictionary(lang: ProofLang) -> Arc<AffixDict> {
|
||||
}
|
||||
|
||||
fn load_installed(lang: ProofLang) -> AffixDict {
|
||||
let (_, name, files) = variety(lang);
|
||||
let key = dictionary_name(lang);
|
||||
let Some((aff, dic)) = crate::hunspell::find(variety(lang).1) else {
|
||||
let Some((aff, dic)) = crate::hunspell::find(files) else {
|
||||
crate::hunspell::clear_load_error(key);
|
||||
log::info!("{} spelling: no Hunspell {key} dictionary installed", variety_name(lang));
|
||||
log::info!("{name} spelling: no Hunspell {key} dictionary installed");
|
||||
return AffixDict::default();
|
||||
};
|
||||
crate::hunspell::note_load(key, &dic, crate::hunspell::load(&aff, &dic)).unwrap_or_else(|e| {
|
||||
log::warn!("{} spelling: {e}", variety_name(lang));
|
||||
log::warn!("{name} spelling: {e}");
|
||||
AffixDict::default()
|
||||
})
|
||||
}
|
||||
@@ -115,7 +107,7 @@ pub fn reload_dictionary() {
|
||||
/// or the system locations now (File › Options › Proofing shows it); `None` when there is none.
|
||||
/// A dictionary given with [`install_dictionary`] has no file and isn't reported.
|
||||
pub fn installed_path(lang: ProofLang) -> Option<std::path::PathBuf> {
|
||||
crate::hunspell::find(variety(lang).1).map(|(_, dic)| dic)
|
||||
crate::hunspell::find(variety(lang).2).map(|(_, dic)| dic)
|
||||
}
|
||||
|
||||
/// Is a dictionary available for `lang`'s variety (spelling is checked)?
|
||||
@@ -208,7 +200,7 @@ mod tests {
|
||||
std::fs::write(dir.join(format!("{name}.aff")), test_dict::AFF).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.dic")), test_dict::DIC).unwrap();
|
||||
for lang in VARIETIES {
|
||||
let found = crate::hunspell::find_in(&dir, variety(lang).1);
|
||||
let found = crate::hunspell::find_in(&dir, variety(lang).2);
|
||||
assert_eq!(found.is_some(), found_by.contains(&lang), "{name}: {lang:?}");
|
||||
if let Some((aff, dic)) = found {
|
||||
assert!(is_correct_with(&crate::hunspell::load(&aff, &dic).unwrap(), "canción", lang));
|
||||
@@ -223,7 +215,7 @@ mod tests {
|
||||
std::fs::write(dir.join(format!("{name}.dic")), test_dict::DIC).unwrap();
|
||||
}
|
||||
for (lang, name) in [(es, "es_ES"), (mx, "es_MX"), (la, "es_419"), (uy, "es_UY")] {
|
||||
assert_eq!(crate::hunspell::find_in(&dir, variety(lang).1).map(|(_, dic)| dic), Some(dir.join(format!("{name}.dic"))));
|
||||
assert_eq!(crate::hunspell::find_in(&dir, variety(lang).2).map(|(_, dic)| dic), Some(dir.join(format!("{name}.dic"))));
|
||||
assert_eq!(dictionary_name(lang), name);
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
|
||||
use crate::ProofLang;
|
||||
use crate::affix::{AffixDict, Casing, capitalize, casing};
|
||||
|
||||
/// Longest word suggestions are looked for, in letters (as in the shared ranking).
|
||||
const MAX_LETTERS: usize = 40;
|
||||
use crate::cs::suggest::MAX_LETTERS;
|
||||
|
||||
/// Spelling suggestions for a word in `lang`'s Spanish, best first, at most `max`, in the word's
|
||||
/// case.
|
||||
|
||||
Reference in new issue
Block a user