From d7bbf0a9d49b89fded9802ef6790d6b6ab887011 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 3 Nov 2026 12:18:17 +0000 Subject: [PATCH 1/5] proof: offer the elided form (J'adore) before the missing space (J adore) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suggestions tried a missing space but never a missing apostrophe, so French "Jadore" got "Adore, J adore, Jade" and never "J'adore". Round 4 now also offers a'b after a short first part (at most 7 letters), ranked before every split. The dictionary's own check decides whether the elision is right, so French, Italian and Catalan elision (l'acqua, dell'anno) work and a dictionary without elided forms is unaffected. Suggestions keep the writer's case and the apostrophe the dictionary's OCONV writes (J’adore). Costs at most one lookup per split. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco --- crates/proof/src/affix_suggest.rs | 55 +++++++++++++++++++++++++++++-- crates/proof/src/it/suggest.rs | 12 +++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/proof/src/affix_suggest.rs b/crates/proof/src/affix_suggest.rs index 3b498542..44ab3a48 100644 --- a/crates/proof/src/affix_suggest.rs +++ b/crates/proof/src/affix_suggest.rs @@ -6,7 +6,8 @@ //! adding a missing accent is the commonest slip and ranks first, while taking away one the //! writer typed ranks after the known confusions; //! 3. the REP table of common confusions (`żeka` → `rzeka`); -//! 4. a missing space (`napewno` → `na pewno`); +//! 4. a missing space (`napewno` → `na pewno`), or a missing apostrophe where the dictionary +//! knows the elided form (`jadore` → `j'adore`, `lacqua` → `l'acqua`), which ranks first; //! 5. keyboard neighbours from the KEY rows, then one typo: a letter left out, added, swapped with //! the next or mistyped (TRY letters); //! 6. when that finds fewer than asked for: the stems that start like the word (MAP relatives @@ -38,6 +39,10 @@ pub(crate) const COST_OTHER_MAP: u32 = 7; const COST_DROP_DIACRITIC: u32 = 11; const COST_REP: u32 = 10; pub(crate) const COST_SPLIT: u32 = 12; +/// A missing apostrophe after an elided word the dictionary knows (`j'adore`, `dell'anno`). +const COST_ELISION: u32 = COST_SPLIT - 1; +/// Longest elided first part tried, in letters (`quoiqu'`, `lorsqu'`, `quell'`). +const MAX_ELIDED: usize = 7; /// A missing space leaving a half shorter than three letters ([`Split::Anywhere`]). const COST_SPLIT_SHORT: u32 = 17; /// A missing space between two longer halves with no short word beside it ([`Split::Near`]). @@ -474,10 +479,18 @@ fn add_confusions(col: &mut Collector, input: &str, t: &Tuning) { } } -/// Round 4: a missing space; both halves must be dictionary words. +/// Round 4: a missing space; both halves must be dictionary words. Before it, a missing +/// apostrophe: the dictionary's own check says whether `a'b` is right (French `j'adore`, Italian +/// and Catalan `l'acqua`, `dell'anno`), so no language list is needed and a dictionary without +/// elided forms never offers one; it ranks before the space (`j adore`). At most one lookup per +/// split, and only after a short first part ([`MAX_ELIDED`]). fn add_splits(col: &mut Collector, input: &str, split: &Split) { - for (i, _) in input.char_indices().skip(1) { + let elides = !input.contains(['\'', '’']); + for (n, (i, _)) in input.char_indices().enumerate().skip(1) { let (Some(a), Some(b)) = (input.get(..i), input.get(i..)) else { continue }; + if elides && n <= MAX_ELIDED { + col.offer(&format!("{a}'{b}"), COST_ELISION); + } if let Some(cost) = split_cost(split, a, b) { col.offer(&format!("{a} {b}"), cost); } @@ -778,4 +791,40 @@ mod tests { assert!(!suggest_in(&d, "kotna", 4, &near).contains(&"kot na".into()), "no stray short half"); assert!(suggest_in(&d, "kotna", 4, &Tuning::DEFAULT).contains(&"kot na".into())); } + + /// A French elision in the shape of the LibreOffice `fr` dictionary (written for this test): + /// long flags named after the elided word, `’` read as `'` and written back as `’`. + const FR_AFF: &str = "SET UTF-8\nFLAG long\nWORDCHARS -’'\nICONV 1\nICONV ’ '\nOCONV 1\nOCONV ' ’\n\ + PFX j' Y 1\nPFX j' 0 j' .\nPFX l' Y 1\nPFX l' 0 l' .\n"; + const FR_DIC: &str = "5\nadore/j'\nami/l'\njade\nj\nl\n"; + + /// Craft-proof bug: French `Jadore` was offered `Adore, J adore, Jade` but never `J’adore`. A + /// missing apostrophe the dictionary accepts comes first, before the missing space, in the + /// writer's case and with the dictionary's apostrophe. + #[test] + fn a_missing_elision_apostrophe_is_offered_first() { + let d = AffixDict::from_hunspell(FR_AFF, FR_DIC, &[]).unwrap(); + assert!(d.check("j'adore") && d.check("j’adore") && !d.check("jadore")); + for (word, want, space) in + [("Jadore", "J’adore", "J adore"), ("jadore", "j’adore", "j adore"), ("JADORE", "J’ADORE", "J ADORE"), ("lami", "l’ami", "l ami")] + { + let got = suggest_in(&d, word, 6, &Tuning::DEFAULT); + assert_eq!(got.first().map(String::as_str), Some(want), "{word}: {got:?}"); + let at = |s: &str| got.iter().position(|g| g == s); + assert!(at(want) < at(space) || at(space).is_none(), "{word}: {got:?}"); + } + // A word the writer gave an apostrophe isn't split again. + assert!(!suggest_in(&d, "j'adorr", 6, &Tuning::DEFAULT).iter().any(|g| g.matches('’').count() > 1)); + } + + /// A dictionary without elided forms offers no apostrophe: the missing space is still first. + #[test] + fn no_apostrophe_without_elided_forms_in_the_dictionary() { + let d = AffixDict::from_hunspell("SET UTF-8\n", "4\nadore\nj\nna\npewno\n", &[]).unwrap(); + for word in ["jadore", "napewno"] { + let got = suggest_in(&d, word, 6, &Tuning::DEFAULT); + assert!(got.iter().all(|g| !g.contains(['\'', '’'])), "{word}: {got:?}"); + } + assert_eq!(suggest_in(&d, "napewno", 6, &Tuning::DEFAULT).first().map(String::as_str), Some("na pewno")); + } } diff --git a/crates/proof/src/it/suggest.rs b/crates/proof/src/it/suggest.rs index 55f86909..6e0ac654 100644 --- a/crates/proof/src/it/suggest.rs +++ b/crates/proof/src/it/suggest.rs @@ -46,6 +46,18 @@ mod tests { assert!(s.iter().any(|x| x == want), "{word} → {s:?}, want {want}"); } + /// Craft-proof bug (French `jadore` → `j’adore`): an Italian elision the dictionary knows + /// (`l'acqua`, `dell'anno`) is offered before the missing space, in the writer's case. + #[test] + fn a_missing_elision_apostrophe_is_offered_first() { + let aff = "SET UTF-8\nWORDCHARS '\nPFX L Y 1\nPFX L 0 l' .\nPFX D Y 1\nPFX D 0 dell' .\n"; + let d = AffixDict::from_hunspell(aff, "4\nacqua/L\nanno/D\nl\ndella\n", &[]).unwrap(); + for (word, want) in [("lacqua", "l'acqua"), ("Lacqua", "L'acqua"), ("dellanno", "dell'anno")] { + let got = suggest_in(&d, word, 6); + assert_eq!(got.first().map(String::as_str), Some(want), "{word}: {got:?}"); + } + } + #[test] fn nosuggest_stems_and_forms_are_never_offered() { crate::affix::tests::assert_nosuggest_is_honoured(suggest_in); From 3622f802e741e226735b52cc6349d02120ddaefa Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 3 Nov 2026 13:24:17 +0000 Subject: [PATCH 2/5] proof: rank only a true elision first; other apostrophe forms after one-letter fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the elision fix: the cheap cost went to every apostrophe form the dictionary accepts, so English possessives (churchs → church's), contractions (neer → ne'er) and Catalan enclitics (acusit → acusi't) jumped ahead of the one-letter fix. A missing apostrophe now ranks first only before a dictionary word of two letters or more (j'adore, l'home, qu'il, dell'anno) and only when the language elides (new Tuning::elision, off for English). Any other form costs COST_MISTYPE + 1, after every one-letter fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco --- crates/proof/src/affix_suggest.rs | 59 ++++++++++++++++++++++++++----- crates/proof/src/english.rs | 5 +-- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/crates/proof/src/affix_suggest.rs b/crates/proof/src/affix_suggest.rs index 44ab3a48..a9cca524 100644 --- a/crates/proof/src/affix_suggest.rs +++ b/crates/proof/src/affix_suggest.rs @@ -39,8 +39,12 @@ pub(crate) const COST_OTHER_MAP: u32 = 7; const COST_DROP_DIACRITIC: u32 = 11; const COST_REP: u32 = 10; pub(crate) const COST_SPLIT: u32 = 12; -/// A missing apostrophe after an elided word the dictionary knows (`j'adore`, `dell'anno`). +/// A missing apostrophe between an elided word and a dictionary word of two letters or more +/// (`j'adore`, `qu'il`, `dell'anno`). const COST_ELISION: u32 = COST_SPLIT - 1; +/// Any other missing apostrophe the dictionary accepts (a possessive `church's`, an enclitic +/// `acusi't`, a contraction `he'd`), ranked after every one-letter fix (`churches`, `acudit`). +const COST_APOSTROPHE: u32 = COST_MISTYPE + 1; /// Longest elided first part tried, in letters (`quoiqu'`, `lorsqu'`, `quell'`). const MAX_ELIDED: usize = 7; /// A missing space leaving a half shorter than three letters ([`Split::Anywhere`]). @@ -76,6 +80,11 @@ pub(crate) struct Tuning { /// Offer a word only the dictionary capitalizes (a name, an abbreviation: `Col`, `Wis`) only /// when the word itself is capitalized, and never as half of a missing space (`Che Que`). pub strict_case: bool, + /// Does the language elide a word before an apostrophe (French `j'adore`, Italian `l'acqua`)? + /// Then a missing apostrophe before a dictionary word ranks first ([`COST_ELISION`]); otherwise + /// every missing apostrophe ranks after the one-letter fixes (English `churchs` → `churches` + /// before `church's`, `neer` → `never` before `ne'er`). + pub elision: bool, } /// Where a missing space may go. @@ -102,6 +111,7 @@ impl Tuning { map_cost, mistype_cost, strict_case: false, + elision: true, }; } @@ -393,7 +403,7 @@ pub(crate) fn suggest_in(d: &AffixDict, word: &str, max: usize, t: &Tuning) -> V let map = groups(d, t); add_map_slips(&mut col, &chars, &map, t); add_confusions(&mut col, &input, t); - add_splits(&mut col, &input, &t.split); + add_splits(&mut col, &input, t); add_key_neighbours(&mut col, &chars); add_typos(&mut col, &chars, t); col.compounds(); @@ -480,18 +490,21 @@ fn add_confusions(col: &mut Collector, input: &str, t: &Tuning) { } /// Round 4: a missing space; both halves must be dictionary words. Before it, a missing -/// apostrophe: the dictionary's own check says whether `a'b` is right (French `j'adore`, Italian -/// and Catalan `l'acqua`, `dell'anno`), so no language list is needed and a dictionary without -/// elided forms never offers one; it ranks before the space (`j adore`). At most one lookup per -/// split, and only after a short first part ([`MAX_ELIDED`]). -fn add_splits(col: &mut Collector, input: &str, split: &Split) { +/// apostrophe: the dictionary's own check says whether `a'b` is right, so no language list is +/// needed and a dictionary without such forms never offers one. A true elision, whose second part +/// is a word itself (French `j'adore`, Italian and Catalan `l'acqua`, `dell'anno`), ranks before +/// the space (`j adore`) and every typo when the language elides ([`Tuning::elision`]); any other +/// form after the one-letter fixes ([`COST_APOSTROPHE`]). At most two lookups per split, and only after a short first part +/// ([`MAX_ELIDED`]). +fn add_splits(col: &mut Collector, input: &str, t: &Tuning) { let elides = !input.contains(['\'', '’']); for (n, (i, _)) in input.char_indices().enumerate().skip(1) { let (Some(a), Some(b)) = (input.get(..i), input.get(i..)) else { continue }; if elides && n <= MAX_ELIDED { - col.offer(&format!("{a}'{b}"), COST_ELISION); + let word = t.elision && b.chars().nth(1).is_some() && col.d.contains_suggestible_with(b, false); + col.offer(&format!("{a}'{b}"), if word { COST_ELISION } else { COST_APOSTROPHE }); } - if let Some(cost) = split_cost(split, a, b) { + if let Some(cost) = split_cost(&t.split, a, b) { col.offer(&format!("{a} {b}"), cost); } } @@ -827,4 +840,32 @@ mod tests { } assert_eq!(suggest_in(&d, "napewno", 6, &Tuning::DEFAULT).first().map(String::as_str), Some("na pewno")); } + + /// Review of the elision fix: an apostrophe form whose second part is no word (a Catalan + /// enclitic `acusi't`, an English possessive `church's`) ranks after the one-letter fixes, + /// while a true elision (`l'home`) still comes first. + #[test] + fn an_apostrophe_before_no_word_ranks_after_one_letter_fixes() { + let aff = "SET UTF-8\nTRY acdehimorstu\nWORDCHARS '\nSFX T Y 1\nSFX T 0 't .\nPFX L Y 1\nPFX L 0 l' .\nSFX S Y 1\nSFX S 0 es .\nSFX M Y 1\nSFX M 0 's .\n"; + let d = AffixDict::from_hunspell(aff, "5\nacudit\nacusi/T\nhome/L\nl\nchurch/SM\n", &[]).unwrap(); + assert!(d.check("acusi't") && d.check("church's")); + for (word, before, after) in [("acusit", "acudit", "acusi't"), ("churchs", "churches", "church's"), ("lhome", "l'home", "l home")] { + let got = suggest_in(&d, word, 8, &Tuning::DEFAULT); + let at = |s: &str| got.iter().position(|g| g == s); + assert!(at(before).is_some() && at(before) < at(after) && at(after).is_some(), "{word}: {got:?}"); + } + assert_eq!(suggest_in(&d, "lhome", 8, &Tuning::DEFAULT).first().map(String::as_str), Some("l'home")); + assert!(suggest_in(&d, "churchs", 8, &Tuning::DEFAULT).iter().position(|g| g == "church") < Some(2), "drop the s"); + } + + /// A language without elision ([`Tuning::elision`], English) ranks every missing apostrophe + /// after the one-letter fixes, even before a word (`neer` → `near` before `ne'er`). + #[test] + fn a_language_without_elision_ranks_apostrophes_late() { + let d = AffixDict::from_hunspell("SET UTF-8\nTRY aenr\nWORDCHARS '\n", "3\nne'er\ner\nnear\n", &[]).unwrap(); + let english = Tuning { elision: false, ..Tuning::DEFAULT }; + assert_eq!(suggest_in(&d, "neer", 4, &english).first().map(String::as_str), Some("near")); + assert!(suggest_in(&d, "neer", 4, &english).contains(&"ne'er".to_string())); + assert_eq!(suggest_in(&d, "neer", 4, &Tuning::DEFAULT).first().map(String::as_str), Some("ne'er"), "an elision"); + } } diff --git a/crates/proof/src/english.rs b/crates/proof/src/english.rs index e821e801..bcf5ee34 100644 --- a/crates/proof/src/english.rs +++ b/crates/proof/src/english.rs @@ -455,8 +455,9 @@ pub(crate) fn suggest(word: &str, max: usize, lang: ProofLang) -> Vec { /// English suggestions from an affix dictionary: its own tables, a missing space only between two /// words of three letters or more and after every one-word candidate (`publically` → `publicly` -/// before `public ally`), and no abbreviation or name offered for a lower-case word (`col`, `Wis`). -const TUNING: Tuning = Tuning { split: Split::Custom(split_cost), strict_case: true, ..Tuning::DEFAULT }; +/// before `public ally`), no abbreviation or name offered for a lower-case word (`col`, `Wis`), and +/// no elision (`churchs` → `churches` before `church's`). +const TUNING: Tuning = Tuning { split: Split::Custom(split_cost), strict_case: true, elision: false, ..Tuning::DEFAULT }; /// The cost of a missing space in English ([`TUNING`]). fn split_cost(a: &str, b: &str) -> Option { From 18cc6533fab394d69bea5426d5e8dee6f71eccf3 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 3 Nov 2026 21:37:45 +0000 Subject: [PATCH 3/5] proof: elision docs match the costs; DEFAULT's elision is gated by the dictionary (review: FredApps) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco --- crates/proof/src/affix_suggest.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/proof/src/affix_suggest.rs b/crates/proof/src/affix_suggest.rs index a9cca524..3bc5c8ac 100644 --- a/crates/proof/src/affix_suggest.rs +++ b/crates/proof/src/affix_suggest.rs @@ -7,7 +7,8 @@ //! writer typed ranks after the known confusions; //! 3. the REP table of common confusions (`żeka` → `rzeka`); //! 4. a missing space (`napewno` → `na pewno`), or a missing apostrophe where the dictionary -//! knows the elided form (`jadore` → `j'adore`, `lacqua` → `l'acqua`), which ranks first; +//! knows the elided form (`jadore` → `j'adore`, `lacqua` → `l'acqua`); an elision ranks just before +//! the missing space, so after the accent, MAP and REP fixes above; //! 5. keyboard neighbours from the KEY rows, then one typo: a letter left out, added, swapped with //! the next or mistyped (TRY letters); //! 6. when that finds fewer than asked for: the stems that start like the word (MAP relatives @@ -80,10 +81,14 @@ pub(crate) struct Tuning { /// Offer a word only the dictionary capitalizes (a name, an abbreviation: `Col`, `Wis`) only /// when the word itself is capitalized, and never as half of a missing space (`Che Que`). pub strict_case: bool, - /// Does the language elide a word before an apostrophe (French `j'adore`, Italian `l'acqua`)? - /// Then a missing apostrophe before a dictionary word ranks first ([`COST_ELISION`]); otherwise - /// every missing apostrophe ranks after the one-letter fixes (English `churchs` → `churches` - /// before `church's`, `neer` → `never` before `ne'er`). + /// Offer a missing apostrophe before a suggestible word of 2+ letters as an elision (French + /// `j'adore`, Italian `l'acqua`), ranked at [`COST_ELISION`]: after the accent, MAP and REP fixes, + /// before the missing space (`j adore`) and the typos. On by default ([`Tuning::DEFAULT`]) because + /// the pack languages share it, so it does not mean "this language elides": the dictionary check + /// gates it, and a dictionary without such forms never offers one. A language that never elides + /// and has apostrophe forms that compete turns it off (English); then every missing apostrophe + /// ranks after the one-letter fixes (`churchs` → `churches` before `church's`, `neer` → `never` + /// before `ne'er`). pub elision: bool, } @@ -493,7 +498,7 @@ fn add_confusions(col: &mut Collector, input: &str, t: &Tuning) { /// apostrophe: the dictionary's own check says whether `a'b` is right, so no language list is /// needed and a dictionary without such forms never offers one. A true elision, whose second part /// is a word itself (French `j'adore`, Italian and Catalan `l'acqua`, `dell'anno`), ranks before -/// the space (`j adore`) and every typo when the language elides ([`Tuning::elision`]); any other +/// the space (`j adore`) and every typo while [`Tuning::elision`] is on; any other /// form after the one-letter fixes ([`COST_APOSTROPHE`]). At most two lookups per split, and only after a short first part /// ([`MAX_ELIDED`]). fn add_splits(col: &mut Collector, input: &str, t: &Tuning) { @@ -812,10 +817,10 @@ mod tests { const FR_DIC: &str = "5\nadore/j'\nami/l'\njade\nj\nl\n"; /// Craft-proof bug: French `Jadore` was offered `Adore, J adore, Jade` but never `J’adore`. A - /// missing apostrophe the dictionary accepts comes first, before the missing space, in the + /// missing apostrophe the dictionary accepts comes before the missing space, in the /// writer's case and with the dictionary's apostrophe. #[test] - fn a_missing_elision_apostrophe_is_offered_first() { + fn a_missing_elision_apostrophe_is_offered_before_the_missing_space() { let d = AffixDict::from_hunspell(FR_AFF, FR_DIC, &[]).unwrap(); assert!(d.check("j'adore") && d.check("j’adore") && !d.check("jadore")); for (word, want, space) in @@ -843,7 +848,7 @@ mod tests { /// Review of the elision fix: an apostrophe form whose second part is no word (a Catalan /// enclitic `acusi't`, an English possessive `church's`) ranks after the one-letter fixes, - /// while a true elision (`l'home`) still comes first. + /// while a true elision (`l'home`) still ranks first among them. #[test] fn an_apostrophe_before_no_word_ranks_after_one_letter_fixes() { let aff = "SET UTF-8\nTRY acdehimorstu\nWORDCHARS '\nSFX T Y 1\nSFX T 0 't .\nPFX L Y 1\nPFX L 0 l' .\nSFX S Y 1\nSFX S 0 es .\nSFX M Y 1\nSFX M 0 's .\n"; From 593316065569397108266ca8c37e775306d8e24c Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 3 Nov 2026 21:46:56 +0000 Subject: [PATCH 4/5] proof: a test pins English's elision: false; elision docs say it ties with dropping a typed accent (review) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco --- crates/proof/src/affix_suggest.rs | 17 +++++++++-------- crates/proof/src/english.rs | 10 ++++++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/proof/src/affix_suggest.rs b/crates/proof/src/affix_suggest.rs index 3bc5c8ac..790d6868 100644 --- a/crates/proof/src/affix_suggest.rs +++ b/crates/proof/src/affix_suggest.rs @@ -7,8 +7,9 @@ //! writer typed ranks after the known confusions; //! 3. the REP table of common confusions (`żeka` → `rzeka`); //! 4. a missing space (`napewno` → `na pewno`), or a missing apostrophe where the dictionary -//! knows the elided form (`jadore` → `j'adore`, `lacqua` → `l'acqua`); an elision ranks just before -//! the missing space, so after the accent, MAP and REP fixes above; +//! knows the elided form (`jadore` → `j'adore`, `lacqua` → `l'acqua`); an elision ranks just +//! before the missing space, so after accent-adding, MAP and REP fixes (tying with dropping a +//! typed accent); //! 5. keyboard neighbours from the KEY rows, then one typo: a letter left out, added, swapped with //! the next or mistyped (TRY letters); //! 6. when that finds fewer than asked for: the stems that start like the word (MAP relatives @@ -82,7 +83,7 @@ pub(crate) struct Tuning { /// when the word itself is capitalized, and never as half of a missing space (`Che Que`). pub strict_case: bool, /// Offer a missing apostrophe before a suggestible word of 2+ letters as an elision (French - /// `j'adore`, Italian `l'acqua`), ranked at [`COST_ELISION`]: after the accent, MAP and REP fixes, + /// `j'adore`, Italian `l'acqua`), ranked at [`COST_ELISION`]: after accent-adding, MAP and REP fixes (a tie with dropping a typed accent), /// before the missing space (`j adore`) and the typos. On by default ([`Tuning::DEFAULT`]) because /// the pack languages share it, so it does not mean "this language elides": the dictionary check /// gates it, and a dictionary without such forms never offers one. A language that never elides @@ -498,14 +499,14 @@ fn add_confusions(col: &mut Collector, input: &str, t: &Tuning) { /// apostrophe: the dictionary's own check says whether `a'b` is right, so no language list is /// needed and a dictionary without such forms never offers one. A true elision, whose second part /// is a word itself (French `j'adore`, Italian and Catalan `l'acqua`, `dell'anno`), ranks before -/// the space (`j adore`) and every typo while [`Tuning::elision`] is on; any other -/// form after the one-letter fixes ([`COST_APOSTROPHE`]). At most two lookups per split, and only after a short first part -/// ([`MAX_ELIDED`]). +/// the space (`j adore`) and every typo while [`Tuning::elision`] is on; any other form ranks +/// after the one-letter fixes ([`COST_APOSTROPHE`]). At most two lookups per split, and only after +/// a short first part ([`MAX_ELIDED`]). fn add_splits(col: &mut Collector, input: &str, t: &Tuning) { - let elides = !input.contains(['\'', '’']); + let no_apostrophe = !input.contains(['\'', '’']); for (n, (i, _)) in input.char_indices().enumerate().skip(1) { let (Some(a), Some(b)) = (input.get(..i), input.get(i..)) else { continue }; - if elides && n <= MAX_ELIDED { + if no_apostrophe && n <= MAX_ELIDED { let word = t.elision && b.chars().nth(1).is_some() && col.d.contains_suggestible_with(b, false); col.offer(&format!("{a}'{b}"), if word { COST_ELISION } else { COST_APOSTROPHE }); } diff --git a/crates/proof/src/english.rs b/crates/proof/src/english.rs index bcf5ee34..6a6ac9a0 100644 --- a/crates/proof/src/english.rs +++ b/crates/proof/src/english.rs @@ -566,6 +566,16 @@ mod tests { } } + /// English never elides: a missing apostrophe ranks after the one-letter fixes (`neer` → `near` + /// before `ne'er`), which only [`TUNING`]'s `elision: false` gives. + #[test] + fn english_ranks_a_missing_apostrophe_after_one_letter_fixes() { + let d = AffixDict::from_hunspell("SET UTF-8\nTRY aenr\nWORDCHARS '\n", "3\nne'er\ner\nnear\n", &[]).unwrap(); + let s = crate::affix_suggest::suggest_in(&d, "neer", 4, &TUNING); + assert_eq!(s.first().map(String::as_str), Some("near"), "{s:?}"); + assert!(s.contains(&"ne'er".to_string()), "{s:?}"); + } + #[test] fn built_in_list_is_the_whole_moby_list() { let d = words(); From 11ed4abc1e56b925225b93e16fb4035e2e13311f Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 3 Nov 2026 22:07:32 +0000 Subject: [PATCH 5/5] proof: the Italian elision test checks dell'anno ranks before dell anno (CodeRabbit) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco --- crates/proof/src/it/suggest.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/proof/src/it/suggest.rs b/crates/proof/src/it/suggest.rs index 6e0ac654..bf846c12 100644 --- a/crates/proof/src/it/suggest.rs +++ b/crates/proof/src/it/suggest.rs @@ -51,11 +51,15 @@ mod tests { #[test] fn a_missing_elision_apostrophe_is_offered_first() { let aff = "SET UTF-8\nWORDCHARS '\nPFX L Y 1\nPFX L 0 l' .\nPFX D Y 1\nPFX D 0 dell' .\n"; - let d = AffixDict::from_hunspell(aff, "4\nacqua/L\nanno/D\nl\ndella\n", &[]).unwrap(); + let d = AffixDict::from_hunspell(aff, "5\nacqua/L\nanno/D\nl\ndella\ndell\n", &[]).unwrap(); for (word, want) in [("lacqua", "l'acqua"), ("Lacqua", "L'acqua"), ("dellanno", "dell'anno")] { let got = suggest_in(&d, word, 6); assert_eq!(got.first().map(String::as_str), Some(want), "{word}: {got:?}"); } + // With both halves words, the missing space is offered too, after the elision. + let got = suggest_in(&d, "dellanno", 6); + let at = |w: &str| got.iter().position(|x| x == w); + assert!(at("dell anno").is_some() && at("dell'anno") < at("dell anno"), "{got:?}"); } #[test]