From cb82e9142862e2921bbbc825bc4f95f9163747f6 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 2 Nov 2026 23:23:15 +0000 Subject: [PATCH] =?UTF-8?q?craft-proof=20=E2=80=94=20a=20run=20of=20spaces?= =?UTF-8?q?=20is=20one=20double-space=20issue=20over=20the=20whole=20run?= =?UTF-8?q?=20in=20every=20language,=20through=20one=20shared=20space=5Fru?= =?UTF-8?q?ns=20helper=20(#496=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/proof/src/cs/grammar.rs | 7 ++-- crates/proof/src/es/grammar.rs | 17 ++-------- crates/proof/src/hr/grammar.rs | 7 ++-- crates/proof/src/id/grammar.rs | 7 ++-- crates/proof/src/it/grammar.rs | 7 ++-- crates/proof/src/lib.rs | 60 ++++++++++++++++++++++++++++++---- crates/proof/src/pl/grammar.rs | 7 ++-- crates/proof/src/pt/grammar.rs | 7 ++-- crates/proof/src/ru/grammar.rs | 7 ++-- 9 files changed, 70 insertions(+), 56 deletions(-) diff --git a/crates/proof/src/cs/grammar.rs b/crates/proof/src/cs/grammar.rs index 8a2590e6..e2d29954 100644 --- a/crates/proof/src/cs/grammar.rs +++ b/crates/proof/src/cs/grammar.rs @@ -62,11 +62,8 @@ fn spaces(text: &str, v: &mut Vec) { v.push(issue(i, i + 1 + p.len_utf8(), "Mezera před interpunkcí".into(), vec![p.to_string()])); } } - for (i, _) in text.match_indices(" ") { - if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) { - continue; - } - v.push(issue(i, i + 2, "Dvojitá mezera".into(), vec![" ".into()])); + for run in crate::space_runs(text) { + v.push(issue(run.start, run.end, "Dvojitá mezera".into(), vec![" ".into()])); } } diff --git a/crates/proof/src/es/grammar.rs b/crates/proof/src/es/grammar.rs index 687ecac3..00b20789 100644 --- a/crates/proof/src/es/grammar.rs +++ b/crates/proof/src/es/grammar.rs @@ -75,21 +75,8 @@ fn spaces(text: &str, lang: ProofLang, v: &mut Vec) { v.push(issue(i, i + 1 + p.len_utf8(), "Espacio antes de un signo de puntuación".into(), vec![p.to_string()], lang)); } } - // Each run of two or more spaces once, over the whole run. - let bytes = text.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if bytes.get(i) != Some(&b' ') { - i += 1; - continue; - } - let start = i; - while bytes.get(i) == Some(&b' ') { - i += 1; - } - if i - start >= 2 { - v.push(issue(start, i, "Espacio doble".into(), vec![" ".into()], lang)); - } + for run in crate::space_runs(text) { + v.push(issue(run.start, run.end, "Espacio doble".into(), vec![" ".into()], lang)); } } diff --git a/crates/proof/src/hr/grammar.rs b/crates/proof/src/hr/grammar.rs index 2605ae88..f4997e9d 100644 --- a/crates/proof/src/hr/grammar.rs +++ b/crates/proof/src/hr/grammar.rs @@ -105,11 +105,8 @@ fn spaces(text: &str, v: &mut Vec) { v.push(issue(i, i + 1 + p.len_utf8(), "Razmak ispred interpunkcijskog znaka".into(), vec![p.to_string()])); } } - for (i, _) in text.match_indices(" ") { - if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) { - continue; - } - v.push(issue(i, i + 2, "Dvostruki razmak".into(), vec![" ".into()])); + for run in crate::space_runs(text) { + v.push(issue(run.start, run.end, "Dvostruki razmak".into(), vec![" ".into()])); } } diff --git a/crates/proof/src/id/grammar.rs b/crates/proof/src/id/grammar.rs index 72ca10c9..6c13b7f1 100644 --- a/crates/proof/src/id/grammar.rs +++ b/crates/proof/src/id/grammar.rs @@ -56,11 +56,8 @@ fn spaces(text: &str, v: &mut Vec) { v.push(issue(i, i + 1 + p.len_utf8(), "Spasi sebelum tanda baca".into(), vec![p.to_string()])); } } - for (i, _) in text.match_indices(" ") { - if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) { - continue; - } - v.push(issue(i, i + 2, "Spasi ganda".into(), vec![" ".into()])); + for run in crate::space_runs(text) { + v.push(issue(run.start, run.end, "Spasi ganda".into(), vec![" ".into()])); } } diff --git a/crates/proof/src/it/grammar.rs b/crates/proof/src/it/grammar.rs index e90e324b..cad690c4 100644 --- a/crates/proof/src/it/grammar.rs +++ b/crates/proof/src/it/grammar.rs @@ -211,11 +211,8 @@ fn spaces(text: &str, v: &mut Vec) { v.push(issue(i, i + 1 + p.len_utf8(), "Spazio prima della punteggiatura".into(), vec![p.to_string()])); } } - for (i, _) in text.match_indices(" ") { - if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) { - continue; - } - v.push(issue(i, i + 2, "Spazio doppio".into(), vec![" ".into()])); + for run in crate::space_runs(text) { + v.push(issue(run.start, run.end, "Spazio doppio".into(), vec![" ".into()])); } } diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 679a9a72..62df1b7e 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -152,6 +152,28 @@ fn dictionary_key(w: &str) -> String { composed(&w.to_lowercase()).into_owned() } +/// Each run of two or more spaces in `text`, once, over the whole run (a fix replaces it with one +/// space). A run of three is one issue over three, not a pair and a leftover. +pub(crate) fn space_runs(text: &str) -> Vec> { + let bytes = text.as_bytes(); + let mut runs = Vec::new(); + let mut i = 0; + while i < bytes.len() { + if bytes.get(i) != Some(&b' ') { + i += 1; + continue; + } + let start = i; + while bytes.get(i) == Some(&b' ') { + i += 1; + } + if i - start >= 2 { + runs.push(start..i); + } + } + runs +} + /// Add a word to the user dictionary ("Add to Dictionary"). It applies to every language. pub fn add_word(w: &str) { if user_words().write().unwrap_or_else(std::sync::PoisonError::into_inner).insert(dictionary_key(w)) { @@ -1098,13 +1120,10 @@ fn grammar_en(text: &str, capital_first: bool) -> Vec { lang: ProofLang::En, }); } - for (i, _) in text.match_indices(" ") { - if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) { - continue; - } + for run in space_runs(text) { v.push(Issue { - start: i, - end: i + 2, + start: run.start, + end: run.end, kind: IssueKind::Grammar, message: "Extra space".into(), suggestions: vec![" ".into()], @@ -2591,6 +2610,35 @@ mod tests { assert!(suggest_in("billabon", 5, au).iter().any(|s| s == "billabong"), "{:?}", suggest_in("billabon", 5, au)); } + /// Review follow-up: a run of spaces is one issue over the whole run, in every language with a + /// double-space rule (a run of three used to leave two after the fix, four three). + #[test] + fn a_run_of_spaces_is_one_issue_in_every_language() { + assert_eq!(space_runs("a b c d "), [3..5, 6..9, 10..14]); + assert!(space_runs("a b c").is_empty() && space_runs("").is_empty() && space_runs(" ").is_empty()); + let langs = [ + ProofLang::En, + ProofLang::EnGb, + ProofLang::Es, + ProofLang::It, + ProofLang::Pt, + ProofLang::Cs, + ProofLang::Hr, + ProofLang::Id, + ProofLang::Pl, + ProofLang::Ru, + ]; + for lang in langs { + for n in 2..=5 { + let text = format!("Aaa{}bbb", " ".repeat(n)); + let hits: Vec<_> = + check_grammar_in(&text, lang).into_iter().filter(|i| i.start <= 3 && i.end > 3 && i.suggestions == [" "]).collect(); + assert_eq!(hits.len(), 1, "{lang:?} {n}: {hits:?}"); + assert_eq!((hits[0].start, hits[0].end), (3, 3 + n), "{lang:?} {n}"); + } + } + } + /// English spelling reads SCOWL 2026 through the affix engine: one dictionary per English, /// its notice embedded, and its ordinals (`COMPOUNDRULE`) understood. #[cfg_attr(not(craft_proof_data), ignore = "needs the Language data (CRAFT_I18N_DIR)")] diff --git a/crates/proof/src/pl/grammar.rs b/crates/proof/src/pl/grammar.rs index d514429e..2fd93f86 100644 --- a/crates/proof/src/pl/grammar.rs +++ b/crates/proof/src/pl/grammar.rs @@ -304,11 +304,8 @@ fn spaces(text: &str, v: &mut Vec) { v.push(issue(i, i + 1 + p.len_utf8(), "Zbędna spacja przed znakiem interpunkcyjnym".into(), vec![p.to_string()])); } } - for (i, _) in text.match_indices(" ") { - if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) { - continue; - } - v.push(issue(i, i + 2, "Podwójna spacja".into(), vec![" ".into()])); + for run in crate::space_runs(text) { + v.push(issue(run.start, run.end, "Podwójna spacja".into(), vec![" ".into()])); } } diff --git a/crates/proof/src/pt/grammar.rs b/crates/proof/src/pt/grammar.rs index be8325bd..fc658e22 100644 --- a/crates/proof/src/pt/grammar.rs +++ b/crates/proof/src/pt/grammar.rs @@ -129,11 +129,8 @@ fn spaces(text: &str, v: &mut Vec) { v.push(issue(i, i + 1 + p.len_utf8(), "Espaço antes da pontuação".into(), vec![p.to_string()])); } } - for (i, _) in text.match_indices(" ") { - if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) { - continue; - } - v.push(issue(i, i + 2, "Espaço duplo".into(), vec![" ".into()])); + for run in crate::space_runs(text) { + v.push(issue(run.start, run.end, "Espaço duplo".into(), vec![" ".into()])); } } diff --git a/crates/proof/src/ru/grammar.rs b/crates/proof/src/ru/grammar.rs index ff8711fb..4d1ce3c2 100644 --- a/crates/proof/src/ru/grammar.rs +++ b/crates/proof/src/ru/grammar.rs @@ -503,11 +503,8 @@ fn spaces(text: &str, v: &mut Vec) { v.push(issue(i, i + 1 + p.len_utf8(), "Лишний пробел перед знаком препинания".into(), vec![p.to_string()])); } } - for (i, _) in text.match_indices(" ") { - if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) { - continue; - } - v.push(issue(i, i + 2, "Двойной пробел".into(), vec![" ".into()])); + for run in crate::space_runs(text) { + v.push(issue(run.start, run.end, "Двойной пробел".into(), vec![" ".into()])); } }