diff --git a/crates/proof/src/cs/suggest.rs b/crates/proof/src/cs/suggest.rs index f9e286fe..4e5ecd17 100644 --- a/crates/proof/src/cs/suggest.rs +++ b/crates/proof/src/cs/suggest.rs @@ -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. diff --git a/crates/proof/src/es/grammar.rs b/crates/proof/src/es/grammar.rs index 35aaa50d..314d7d3a 100644 --- a/crates/proof/src/es/grammar.rs +++ b/crates/proof/src/es/grammar.rs @@ -26,13 +26,13 @@ pub fn check(text: &str, lang: ProofLang) -> Vec { } fn repeated_words(text: &str, ws: &[(usize, usize)], lang: ProofLang, v: &mut Vec) { + // 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::().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::().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) { } } -/// 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) { 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) { 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; } diff --git a/crates/proof/src/es/mod.rs b/crates/proof/src/es/mod.rs index 46d7ab37..94c6511a 100644 --- a/crates/proof/src/es/mod.rs +++ b/crates/proof/src/es/mod.rs @@ -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 { } 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 { - 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(); diff --git a/crates/proof/src/es/suggest.rs b/crates/proof/src/es/suggest.rs index 01c8e4a5..b42efa2e 100644 --- a/crates/proof/src/es/suggest.rs +++ b/crates/proof/src/es/suggest.rs @@ -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.