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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* storytold/wordcraft#1741: main's Vietnamese proofing test passes the new list-item argument

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* storytold/wordcraft#1741: the third call in main's Vietnamese proofing test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-18 09:57:15 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Dmitrii Khlapov
parent 5fc80099e7
commit de00c8a16a
8 changed files with 114 additions and 30 deletions
+9 -3
View File
@@ -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<Issue> {
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<Issue> {
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<Issue>) {
}
}
fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
fn sentence_capitals(text: &str, ws: &[(usize, usize)], capital_first: bool, v: &mut Vec<Issue>) {
// 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(""))
+9 -3
View File
@@ -208,8 +208,8 @@ fn spaces(text: &str, v: &mut Vec<Issue>) {
}
}
fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
let mut after_end = true;
fn sentence_capitals(text: &str, ws: &[(usize, usize)], capital_first: bool, v: &mut Vec<Issue>) {
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<Issue>) {
/// Grammar issues in a paragraph of Italian text, in order.
pub fn check(text: &str) -> Vec<Issue> {
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<Issue> {
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);
+58 -11
View File
@@ -653,14 +653,21 @@ pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec<String> {
/// ([`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<Issue> {
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<Issue> {
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<Issue> {
/// 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<usize>, ProofLang)]) -> Vec<Issue> {
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<usize>, ProofLang)], opts: &Options, grammar: bool) -> Vec<Issue> {
///
/// `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<usize>, ProofLang)], opts: &Options, grammar: bool, list_item: bool) -> Vec<Issue> {
// Sorted, empty ones dropped, neighbours of one language merged.
let mut sorted: Vec<&(Range<usize>, 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<usize>, 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<usize>, ProofLang)], opts: &O
/// Grammar issues in a paragraph of English text.
pub fn check_grammar(text: &str) -> Vec<Issue> {
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<Issue> {
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<Issue> {
});
}
// 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<String> {
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);
+9 -3
View File
@@ -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<Issue> {
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<Issue> {
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<Issue>) {
fn sentence_capitals(text: &str, ws: &[(usize, usize)], capital_first: bool, v: &mut Vec<Issue>) {
// 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(""))
+9 -3
View File
@@ -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<Issue> {
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<Issue> {
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<Issue>) {
}
}
fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
fn sentence_capitals(text: &str, ws: &[(usize, usize)], capital_first: bool, v: &mut Vec<Issue>) {
// 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(""))
+9 -3
View File
@@ -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<Issue> {
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<Issue> {
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<Issue>) {
fn sentence_capitals(text: &str, ws: &[(usize, usize)], capital_first: bool, v: &mut Vec<Issue>) {
// 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 {
+8 -1
View File
@@ -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<Issue> {
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<Issue> {
let mut v = Vec::new();
let ws = words(text);
@@ -240,7 +246,8 @@ pub fn check(text: &str) -> Vec<Issue> {
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);
+3 -3
View File
@@ -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"]);
}