From de00c8a16afafdca7207722e50d582de8805eabd Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 15:57:15 +0200 Subject: [PATCH] Lowercase list items no longer get a grammar underline (storytold/wordcraft#1741) (storytold/wordcraft#1783) * storytold/wordcraft#1741: WIP * storytold/wordcraft#1741: list items no longer get the sentence-capital grammar squiggle The English "Capitalize the first word of a sentence" rule skips the first word of a list paragraph (own or style numbering); later sentences are still checked. check_text_with and paragraph_issues take a list_item flag. Co-Authored-By: Claude Opus 5.5 * storytold/wordcraft#1741: review fixes Thread the capital-first flag into the Polish, Croatian, Italian, Turkish and Portuguese grammar rules; test the drawn squiggles through layout() for own, style and num 0 numbering; tidy the proof test. Co-Authored-By: Claude Opus 5.5 * storytold/wordcraft#1741: main's Vietnamese proofing test passes the new list-item argument Co-Authored-By: Claude Opus 5.5 (1M context) * storytold/wordcraft#1741: the third call in main's Vietnamese proofing test Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/proof/src/hr/grammar.rs | 12 ++++-- crates/proof/src/it/grammar.rs | 12 ++++-- crates/proof/src/lib.rs | 69 ++++++++++++++++++++++++++++------ crates/proof/src/pl/grammar.rs | 12 ++++-- crates/proof/src/pt/grammar.rs | 12 ++++-- crates/proof/src/ru/grammar.rs | 12 ++++-- crates/proof/src/tr/grammar.rs | 9 ++++- crates/proof/src/vi.rs | 6 +-- 8 files changed, 114 insertions(+), 30 deletions(-) diff --git a/crates/proof/src/hr/grammar.rs b/crates/proof/src/hr/grammar.rs index 475a43e9..517c5623 100644 --- a/crates/proof/src/hr/grammar.rs +++ b/crates/proof/src/hr/grammar.rs @@ -51,11 +51,17 @@ fn ends_sentence(gap: &str, prev: &str) -> bool { /// Grammar issues in a paragraph of Croatian text, in order. pub fn check(text: &str) -> Vec { + check_with(text, true) +} + +/// [`check`] where `capital_first` says whether the text's first word must start a sentence with a capital +/// (false for a list item, which may be a fragment). +pub fn check_with(text: &str, capital_first: bool) -> Vec { let ws: Vec<(usize, usize)> = words(text); let mut v = Vec::new(); repeated_words(text, &ws, &mut v); spaces(text, &mut v); - sentence_capitals(text, &ws, &mut v); + sentence_capitals(text, &ws, capital_first, &mut v); doubled_punctuation(text, &mut v); v.sort_by_key(|i| (i.start, i.end)); v @@ -107,14 +113,14 @@ fn spaces(text: &str, v: &mut Vec) { } } -fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec) { +fn sentence_capitals(text: &str, ws: &[(usize, usize)], capital_first: bool, v: &mut Vec) { // List items (`– maçãs,`) may start in lower case. let list_item = text.trim_end().ends_with([',', ';']); for (k, &(a, b)) in ws.iter().enumerate() { let Some(w) = text.get(a..b) else { continue }; let starts = if k == 0 { let before = text.get(..a).unwrap_or(""); - !list_item && before.chars().all(|c| c.is_whitespace() || OPENING.contains(&c)) + capital_first && !list_item && before.chars().all(|c| c.is_whitespace() || OPENING.contains(&c)) } else { let Some(&(pa, pb)) = ws.get(k - 1) else { continue }; ends_sentence(text.get(pb..a).unwrap_or(""), text.get(pa..pb).unwrap_or("")) diff --git a/crates/proof/src/it/grammar.rs b/crates/proof/src/it/grammar.rs index b6b983fc..235012bd 100644 --- a/crates/proof/src/it/grammar.rs +++ b/crates/proof/src/it/grammar.rs @@ -208,8 +208,8 @@ fn spaces(text: &str, v: &mut Vec) { } } -fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec) { - let mut after_end = true; +fn sentence_capitals(text: &str, ws: &[(usize, usize)], capital_first: bool, v: &mut Vec) { + let mut after_end = capital_first; for (a, b) in ws { let Some(w) = text.get(*a..*b) else { continue }; if after_end && w.chars().next().is_some_and(char::is_lowercase) && w.chars().all(char::is_alphabetic) { @@ -327,11 +327,17 @@ fn un_apostrophe(text: &str, ws: &[(usize, usize)], v: &mut Vec) { /// Grammar issues in a paragraph of Italian text, in order. pub fn check(text: &str) -> Vec { + check_with(text, true) +} + +/// [`check`] where `capital_first` says whether the text's first word must start a sentence with a capital +/// (false for a list item, which may be a fragment). +pub fn check_with(text: &str, capital_first: bool) -> Vec { let ws: Vec<(usize, usize)> = words(text); let mut v = Vec::new(); repeated_words(text, &ws, &mut v); spaces(text, &mut v); - sentence_capitals(text, &ws, &mut v); + sentence_capitals(text, &ws, capital_first, &mut v); doubled_punctuation(text, &mut v); po(text, &ws, &mut v); qual_e(text, &ws, &mut v); diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index ea42c333..627853a0 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -653,14 +653,21 @@ pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec { /// ([`pl::grammar`]), the Portuguese ones ([`pt::grammar`]), the Italian ones ([`it::grammar`]), /// the Turkish ones ([`tr::grammar`]), Czech and Indonesian generic checks ([`cs::grammar`], [`id::grammar`]), or none for a language WordCraft doesn't proof. pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec { + grammar_in(text, lang, true) +} + +/// [`check_grammar_in`] where `capital_first` says whether the first word must be capitalized +/// (false for a list item). +fn grammar_in(text: &str, lang: ProofLang, capital_first: bool) -> Vec { match lang { - ProofLang::En => check_grammar(text), - ProofLang::Pl => pl::grammar::check(text), - ProofLang::Hr => hr::grammar::check(text), - ProofLang::Pt => pt::grammar::check(text), - ProofLang::It => it::grammar::check(text), - ProofLang::Tr => tr::grammar::check(text), - ProofLang::Ru => ru::grammar::check(text), + ProofLang::En => grammar_en(text, capital_first), + ProofLang::Pl => pl::grammar::check_with(text, capital_first), + ProofLang::Hr => hr::grammar::check_with(text, capital_first), + ProofLang::Pt => pt::grammar::check_with(text, capital_first), + ProofLang::It => it::grammar::check_with(text, capital_first), + ProofLang::Tr => tr::grammar::check_with(text, capital_first), + ProofLang::Ru => ru::grammar::check_with(text, capital_first), + // Czech has no sentence-capital rule, so a list item needs no exception. ProofLang::Cs => cs::grammar::check(text), ProofLang::Id => id::grammar::check(text), ProofLang::Bg | ProofLang::Other => Vec::new(), @@ -674,12 +681,15 @@ pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec { /// inside that language's spans: an English quotation in a Polish paragraph gets English /// spelling and is never told a Polish rule. pub fn check_text(text: &str, spans: &[(Range, ProofLang)]) -> Vec { - check_text_with(text, spans, &Options::default(), true) + check_text_with(text, spans, &Options::default(), true, false) } /// [`check_text`] with the File › Options › Proofing choices: what spelling skips, and whether /// grammar is checked at all. -pub fn check_text_with(text: &str, spans: &[(Range, ProofLang)], opts: &Options, grammar: bool) -> Vec { +/// +/// `list_item` marks a list paragraph: items are often fragments, so the English rule asking for +/// a capital on the paragraph's first word is off for it (later sentences are still checked). +pub fn check_text_with(text: &str, spans: &[(Range, ProofLang)], opts: &Options, grammar: bool, list_item: bool) -> Vec { // Sorted, empty ones dropped, neighbours of one language merged. let mut sorted: Vec<&(Range, ProofLang)> = spans.iter().filter(|(r, _)| r.start < r.end).collect(); sorted.sort_by_key(|s| s.0.start); @@ -723,7 +733,8 @@ pub fn check_text_with(text: &str, spans: &[(Range, ProofLang)], opts: &O langs.sort(); langs.dedup(); for lang in langs { - v.extend(check_grammar_in(text, lang).into_iter().filter(|i| lang_at(i.start) == lang)); + let found = grammar_in(text, lang, !list_item); + v.extend(found.into_iter().filter(|i| lang_at(i.start) == lang)); } v.sort_by_key(|i| (i.start, i.end)); v @@ -731,6 +742,12 @@ pub fn check_text_with(text: &str, spans: &[(Range, ProofLang)], opts: &O /// Grammar issues in a paragraph of English text. pub fn check_grammar(text: &str) -> Vec { + grammar_en(text, true) +} + +/// The English rules; `capital_first` is whether the text's first word must start a sentence +/// with a capital (false for a list item, which may be a fragment). +fn grammar_en(text: &str, capital_first: bool) -> Vec { let mut v = Vec::new(); let ws = words(text); for pair in ws.windows(2) { @@ -803,7 +820,7 @@ pub fn check_grammar(text: &str) -> Vec { }); } // Sentence start capital. - let mut after_end = true; + let mut after_end = capital_first; for (a, b) in &ws { let Some(w) = text.get(*a..*b) else { continue }; if after_end && w.chars().next().is_some_and(char::is_lowercase) && w.chars().all(char::is_alphabetic) { @@ -892,6 +909,36 @@ mod tests { assert!(is_correct_in("chrząszcz", ProofLang::Pl) && !is_correct_in("chrzonszcz", ProofLang::Pl)); } + /// #1741: a list item may be a fragment, so its first word needs no capital in any language + /// with the rule; a plain paragraph's does, and later sentences in the item are still checked. + #[test] + fn list_item_first_word_needs_no_capital() { + let grammar = |t: &str, lang: ProofLang, list: bool| -> Vec { + check_text_with(t, &[(0..t.len(), lang)], &Options::default(), true, list) + .into_iter() + .filter(|i| i.kind == IssueKind::Grammar) + .filter_map(|i| t.get(i.start..i.end).map(str::to_string)) + .collect() + }; + for (lang, text) in [ + (ProofLang::En, "item one"), + (ProofLang::Pl, "jabłka i gruszki"), + (ProofLang::Hr, "jabuke i kruške"), + (ProofLang::It, "mele e pere"), + (ProofLang::Tr, "elma ve armut"), + (ProofLang::Pt, "maçãs e peras"), + (ProofLang::Ru, "яблоки и груши"), + ] { + let first = text.split(' ').next().unwrap(); + assert!(grammar(text, lang, true).is_empty(), "{lang:?} list item: {:?}", grammar(text, lang, true)); + assert_eq!(grammar(text, lang, false), [first], "{lang:?} plain paragraph"); + } + assert_eq!(grammar("hello world.", ProofLang::En, false), ["hello"]); + // Later sentences in a list item are still checked. + assert_eq!(grammar("item one. then two", ProofLang::En, true), ["then"]); + assert_eq!(grammar("elma ve armut. sonra kiraz", ProofLang::Tr, true), ["sonra"]); + } + #[test] fn dictionary_loads() { assert!(dict::Dictionary::en_us().len() > 100_000); diff --git a/crates/proof/src/pl/grammar.rs b/crates/proof/src/pl/grammar.rs index ec9d6771..4289f0c7 100644 --- a/crates/proof/src/pl/grammar.rs +++ b/crates/proof/src/pl/grammar.rs @@ -245,11 +245,17 @@ fn is_consonant(c: char) -> bool { /// Grammar issues in a paragraph of Polish text, in order. pub fn check(text: &str) -> Vec { + check_with(text, true) +} + +/// [`check`] where `capital_first` says whether the text's first word must start a sentence with a capital +/// (false for a list item, which may be a fragment). +pub fn check_with(text: &str, capital_first: bool) -> Vec { let ws: Vec<(usize, usize)> = words(text); let mut v = Vec::new(); repeated_words(text, &ws, &mut v); spaces(text, &mut v); - sentence_capitals(text, &ws, &mut v); + sentence_capitals(text, &ws, capital_first, &mut v); missing_commas(text, &ws, &mut v); we_ze(text, &ws, &mut v); doubled_punctuation(text, &mut v); @@ -330,14 +336,14 @@ fn ends_sentence(gap: &str, prev: &str) -> bool { rest.starts_with(char::is_whitespace) && rest.trim_start().chars().all(|c| OPENING.contains(&c)) } -fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec) { +fn sentence_capitals(text: &str, ws: &[(usize, usize)], capital_first: bool, v: &mut Vec) { // List items (`– jabłka,`) may start in lower case. let list_item = text.trim_end().ends_with([',', ';']); for (k, &(a, b)) in ws.iter().enumerate() { let Some(w) = text.get(a..b) else { continue }; let starts = if k == 0 { let before = text.get(..a).unwrap_or(""); - !list_item && before.chars().all(|c| c.is_whitespace() || OPENING.contains(&c)) + capital_first && !list_item && before.chars().all(|c| c.is_whitespace() || OPENING.contains(&c)) } else { let Some(&(pa, pb)) = ws.get(k - 1) else { continue }; ends_sentence(text.get(pb..a).unwrap_or(""), text.get(pa..pb).unwrap_or("")) diff --git a/crates/proof/src/pt/grammar.rs b/crates/proof/src/pt/grammar.rs index c5109de7..6b72e322 100644 --- a/crates/proof/src/pt/grammar.rs +++ b/crates/proof/src/pt/grammar.rs @@ -68,11 +68,17 @@ fn ends_sentence(gap: &str, prev: &str) -> bool { /// Grammar issues in a paragraph of Portuguese text, in order. pub fn check(text: &str) -> Vec { + check_with(text, true) +} + +/// [`check`] where `capital_first` says whether the text's first word must start a sentence with a capital +/// (false for a list item, which may be a fragment). +pub fn check_with(text: &str, capital_first: bool) -> Vec { let ws: Vec<(usize, usize)> = words(text); let mut v = Vec::new(); repeated_words(text, &ws, &mut v); spaces(text, &mut v); - sentence_capitals(text, &ws, &mut v); + sentence_capitals(text, &ws, capital_first, &mut v); doubled_punctuation(text, &mut v); v.sort_by_key(|i| (i.start, i.end)); v @@ -124,14 +130,14 @@ fn spaces(text: &str, v: &mut Vec) { } } -fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec) { +fn sentence_capitals(text: &str, ws: &[(usize, usize)], capital_first: bool, v: &mut Vec) { // List items (`– maçãs,`) may start in lower case. let list_item = text.trim_end().ends_with([',', ';']); for (k, &(a, b)) in ws.iter().enumerate() { let Some(w) = text.get(a..b) else { continue }; let starts = if k == 0 { let before = text.get(..a).unwrap_or(""); - !list_item && before.chars().all(|c| c.is_whitespace() || OPENING.contains(&c)) + capital_first && !list_item && before.chars().all(|c| c.is_whitespace() || OPENING.contains(&c)) } else { let Some(&(pa, pb)) = ws.get(k - 1) else { continue }; ends_sentence(text.get(pb..a).unwrap_or(""), text.get(pa..pb).unwrap_or("")) diff --git a/crates/proof/src/ru/grammar.rs b/crates/proof/src/ru/grammar.rs index 9f68df77..70b9a1e6 100644 --- a/crates/proof/src/ru/grammar.rs +++ b/crates/proof/src/ru/grammar.rs @@ -441,11 +441,17 @@ fn capitalize(w: &str) -> String { /// Grammar issues in a paragraph of Russian text, in order. pub fn check(text: &str) -> Vec { + check_with(text, true) +} + +/// [`check`] where `capital_first` says whether the text's first word must start a sentence with a capital +/// (false for a list item, which may be a fragment). +pub fn check_with(text: &str, capital_first: bool) -> Vec { let ws: Vec<(usize, usize)> = words(text); let mut v = Vec::new(); repeated_words(text, &ws, &mut v); spaces(text, &mut v); - sentence_capitals(text, &ws, &mut v); + sentence_capitals(text, &ws, capital_first, &mut v); missing_commas(text, &ws, &mut v); doubled_punctuation(text, &mut v); v.sort_by_key(|i| (i.start, i.end)); @@ -525,9 +531,9 @@ fn ends_sentence(gap: &str, prev: &str) -> bool { rest.starts_with(char::is_whitespace) && rest.trim_start().chars().all(|c| OPENING.contains(&c)) } -fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec) { +fn sentence_capitals(text: &str, ws: &[(usize, usize)], capital_first: bool, v: &mut Vec) { // List items (`– яблоки,`) may start in lower case. - let list_item = text.trim_end().ends_with([',', ';']); + let list_item = !capital_first || text.trim_end().ends_with([',', ';']); for (k, &(a, b)) in ws.iter().enumerate() { let Some(w) = text.get(a..b) else { continue }; let starts = if k == 0 { diff --git a/crates/proof/src/tr/grammar.rs b/crates/proof/src/tr/grammar.rs index f0598a17..b17e89cb 100644 --- a/crates/proof/src/tr/grammar.rs +++ b/crates/proof/src/tr/grammar.rs @@ -132,6 +132,12 @@ const CONJUNCTIONS_NO_POST_COMMA: &[&str] = &["ve", "veya", "yahut", "veyahut", /// Check grammar and punctuation in a Turkish text block. pub fn check(text: &str) -> Vec { + check_with(text, true) +} + +/// [`check`] where `capital_first` says whether the text's first word must start a sentence with a capital +/// (false for a list item, which may be a fragment). +pub fn check_with(text: &str, capital_first: bool) -> Vec { let mut v = Vec::new(); let ws = words(text); @@ -240,7 +246,8 @@ pub fn check(text: &str) -> Vec { for (i, &(w_start, w_end)) in ws.iter().enumerate() { if i == 0 { // First word in paragraph - if let Some(w) = text.get(w_start..w_end) + if capital_first + && let Some(w) = text.get(w_start..w_end) && w.chars().next().is_some_and(char::is_lowercase) { let cap = turkish_capitalize(w); diff --git a/crates/proof/src/vi.rs b/crates/proof/src/vi.rs index bb24b88e..1abf7d4a 100644 --- a/crates/proof/src/vi.rs +++ b/crates/proof/src/vi.rs @@ -78,15 +78,15 @@ mod checker_tests { fn vietnamese_paragraphs_under_english_get_no_marks() { let text = "Hôm nay tôi đi học, trời rất đẹp và mát mẻ."; let spans = [(0..text.len(), ProofLang::En)]; - assert!(check_text_with(text, &spans, &Options::default(), true).is_empty()); + assert!(check_text_with(text, &spans, &Options::default(), true, false).is_empty()); // No language at all (the document default) behaves the same. - assert!(check_text_with(text, &[], &Options::default(), true).is_empty()); + assert!(check_text_with(text, &[], &Options::default(), true, false).is_empty()); } #[test] fn english_keeps_its_marks_and_skips_vietnamese_names() { let text = "We met Nguyễn yesterdy at the office."; - let issues = check_text_with(text, &[(0..text.len(), ProofLang::En)], &Options::default(), false); + let issues = check_text_with(text, &[(0..text.len(), ProofLang::En)], &Options::default(), false, false); let flagged: Vec<&str> = issues.iter().filter_map(|i| text.get(i.start..i.end)).collect(); assert_eq!(flagged, ["yesterdy"]); }