mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-07 12:13:20 +00:00
CP1.2: English without Language data uses the whole Moby list and every installed English dictionary
Review of craft-libs#482: - The built-in fallback is now the full public-domain Moby word list (data/en-words.dic, words only, 343,992 bytes, from craft-i18n's en-us.dic by examples/en_words.rs) plus WordCraft's own en-extra.txt (MIT OR Apache-2.0). An unknown word is misspelled; the near-miss scan (which flagged fiber, theater, organize… and took 14 s on a 2,000-word token) is gone. Suggestions are the words one edit away, looked up, for words of up to 32 letters. spelling_scales_over_one_long_token runs without the data, with a time bound. - English Hunspell dictionaries ignore compounding (COMPOUNDMIN, COMPOUNDRULE…; their ONLYINCOMPOUND stems are left out) and OCONV instead of refusing the file, and a file that can't be read is skipped for the next name or folder. - A load that started before english::reload() isn't kept (reloads counter, as slot.rs); Englishes that use the same .dic share one copy. en-GB/en-CA falling back to en_US is documented. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
961162ad15
commit
c80b6225f1
+2
-1
@@ -14,7 +14,8 @@ author, source and license. Library code and original assets are under the repos
|
||||
| `crates/cms/tests/data/FOGRA39.txt` (unmodified) | Fogra | [Fogra characterisation data](https://fogra.org/en/downloads/work-tools/characterisation-data), `FOGRA39_to_FOGRA60_v2.zip` | Fogra terms: free use and redistribution, unmodified, with Fogra identified as the source; "FOGRA39" identifies the reference data only and implies no certification, approval or endorsement by Fogra (full text in [NOTICE](NOTICE)) |
|
||||
| `crates/i18n/data/collation.postcard` | Unicode, Inc. (ICU 78.1 collation data, CLDR 48.2.1); generated by `crates/i18n/data/generate-collation.sh` | [ICU](https://github.com/unicode-org/icu/releases/tag/release-78.1rc) | Unicode-3.0 |
|
||||
| `crates/text/assets/hyphen/en-us.dic` | Grady Ward (Moby Hyphenator II), converted by DesignCraft | [Project Gutenberg eBook #3204](https://www.gutenberg.org/ebooks/3204) | Public domain |
|
||||
| `crates/proof/data/en-core.txt` | Grady Ward (Moby Hyphenator II word list); selected by craft-proof contributors with `crates/proof/examples/en_core.rs`, ranked by word frequency in 50 public-domain Project Gutenberg books | [Project Gutenberg eBook #3204](https://www.gutenberg.org/ebooks/3204), via craft-i18n `language-data/en-us/en-us.dic` | Public domain |
|
||||
| `crates/proof/data/en-words.dic` | Grady Ward (Moby Hyphenator II word list); break points dropped by craft-proof contributors with `crates/proof/examples/en_words.rs` | [Project Gutenberg eBook #3204](https://www.gutenberg.org/ebooks/3204), via craft-i18n `language-data/en-us/en-us.dic` | Public domain |
|
||||
| `crates/proof/data/en-extra.txt` | WordCraft contributors (original list, no other dictionary copied) | craft-i18n `language-data/en-us/en-extra.txt` | MIT OR Apache-2.0 |
|
||||
| `crates/text/assets/hyphen/en-us.pat` | DesignCraft contributors, trained from the Moby Hyphenator II list | `crates/text/examples/hyphgen.rs` | CC0-1.0 |
|
||||
| Patterns embedded through `hypher` (optional `craft-text` language features) | hyph-utf8 authors, per language | [tex-hyphen](https://github.com/hyphenation/tex-hyphen), as packaged in [hypher](https://github.com/typst/hypher/tree/main/patterns) | Per file header: LPPL, MIT, BSD-3-Clause, Unlicense or a permit-any-use notice (or a choice including one); Slovak (`hyph-sk.tex`, embedded whenever `hypher/slovak` is on: feature `slovak-gpl`, or another dependency through feature unification) is GPL-2.0-or-later; Spanish (`hyph-es.tex` 5.0, Javier Bezos, CervanTeX) is MIT |
|
||||
| Word-break dictionaries and LSTM models embedded through `icu_segmenter_data` (optional `craft-text` features `thai`, `lao`, `khmer`, `burmese`) | Unicode, Inc. and ICU4X contributors | [ICU4X](https://github.com/unicode-org/icu4x) | Unicode-3.0 |
|
||||
|
||||
@@ -21,9 +21,10 @@ crates/cms/tests/data/FOGRA39.txt: FOGRA39 characterisation data, distributed un
|
||||
imply certification, approval or endorsement by Fogra."
|
||||
crates/text/assets/hyphen/en-us.dic: public domain, derived from the Moby Hyphenator II list by
|
||||
Grady Ward (Project Gutenberg eBook #3204).
|
||||
crates/proof/data/en-core.txt: public domain, about 11,000 words of the Moby Hyphenator II list by
|
||||
Grady Ward (Project Gutenberg eBook #3204), ranked by frequency in public-domain Project
|
||||
Gutenberg books.
|
||||
crates/proof/data/en-words.dic: public domain, the words of the Moby Hyphenator II list by Grady
|
||||
Ward (Project Gutenberg eBook #3204), without its break points.
|
||||
crates/proof/data/en-extra.txt: MIT OR Apache-2.0, WordCraft contributors' own list of modern
|
||||
English words (a copy of craft-i18n's language-data/en-us/en-extra.txt).
|
||||
crates/text/assets/hyphen/en-us.pat: CC0-1.0, trained from en-us.dic by DesignCraft's trainer.
|
||||
Hyphenation patterns for other languages, embedded through the hypher crate only when the
|
||||
matching craft-text feature is enabled (none are by default). They come from hyph-utf8,
|
||||
|
||||
+20
-15
@@ -13,7 +13,7 @@ craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "<commit>
|
||||
|
||||
| Language | Spelling | Grammar | Hyphenation |
|
||||
|---|---|---|---|
|
||||
| English (en-US, en-GB and the British-spelling Englishes, en-CA) | Moby word list + `en-extra.txt`, dialect variants (`en-variants.txt`); without them the installed Hunspell English, else the built-in core list ([below](#english-is-always-checked)) | yes | Moby list, our patterns, heuristic |
|
||||
| English (en-US, en-GB and the British-spelling Englishes, en-CA) | Moby word list + `en-extra.txt`, dialect variants (`en-variants.txt`); without them the installed Hunspell English, else the built-in Moby list ([below](#english-is-always-checked)) | yes | Moby list, our patterns, heuristic |
|
||||
| Polish, Portuguese, Italian | the installed Hunspell dictionary | yes | TeX patterns |
|
||||
| Croatian, Turkish, Russian, Czech, Indonesian, Spanish (es, es-MX, es-419, es-UY) | the installed Hunspell dictionary (Russian also `ru-extra.txt`) | yes | Turkish and Russian rules; English fallback otherwise |
|
||||
| German | — | — | TeX patterns |
|
||||
@@ -55,8 +55,9 @@ The word lists and patterns live in [craft-i18n](https://github.com/storytold/cr
|
||||
grammar and the Hunspell languages work as before.
|
||||
- `CRAFT_I18N_REQUIRED=1` (releases) makes a missing directory or file an error.
|
||||
|
||||
Tests that pin results of the data are ignored without it; `src/no_data.rs` runs every public
|
||||
entry point with no data embedded.
|
||||
Tests that pin results of the data run only when every file is embedded (`craft_proof_data`);
|
||||
`src/no_data.rs` runs every public entry point when none is (`craft_proof_no_data`). A partial
|
||||
data folder runs neither set.
|
||||
|
||||
### English is always checked
|
||||
|
||||
@@ -64,23 +65,27 @@ English spelling uses the first of these that is there; `english_source(lang)` s
|
||||
|
||||
1. **Embedded:** the Moby word list from `CRAFT_I18N_DIR` (above). Nothing below is used then.
|
||||
2. **System Hunspell:** an English `.aff`/`.dic` pair in the dictionaries folder or the system
|
||||
locations, the run's English first (`en_US`, `en_GB`, `en_CA`…), then another English. None on
|
||||
the web.
|
||||
3. **Core list:** `data/en-core.txt`, about 11,000 common words built into the crate (inflections
|
||||
come from the same rules as the Moby list's). It marks only near misses of common words: two
|
||||
letters swapped (`teh`, `recieved`), or one letter added, missing or changed in a lower-case
|
||||
word of four letters or more (`definately`). Other words it doesn't know (rare words, names,
|
||||
terms) are taken as right.
|
||||
locations, the run's English first (`en_US`, `en_GB`, `en_CA`…), then another English: en-GB
|
||||
and en-CA use an `en_US` dictionary when no regional one is installed. A file that can't be
|
||||
read is skipped for the next name or folder; one file is read once for every English that uses
|
||||
it. Compounding (`COMPOUNDMIN`, `COMPOUNDRULE`…, used by English dictionaries only for
|
||||
ordinals) and `OCONV` are ignored for English rather than refused. None on the web.
|
||||
3. **Built-in list:** `data/en-words.dic`, the whole Moby word list (about 165,000 words, 344 KB
|
||||
compressed, words only) built into the crate, with `data/en-extra.txt` (WordCraft's supplement
|
||||
of modern words, a copy of craft-i18n's) and the same inflection rules as the embedded list. A
|
||||
word it doesn't know is misspelled. Suggestions are the words one edit away (swap, missing,
|
||||
added or changed letter), looked up in the list, for words of up to 32 letters.
|
||||
|
||||
A dictionary that is there but can't be read (encoding, size, parse error, no words) is skipped,
|
||||
and `dictionary_load_error(lang)` gives the reason; it never panics. Text in other languages is
|
||||
never checked against English: a language without its dictionary has no spelling marks.
|
||||
|
||||
The core list is public domain. Every word is from the Moby Hyphenator II list by Grady Ward
|
||||
(Project Gutenberg eBook #3204, as in craft-i18n's `language-data/en-us/en-us.dic`); 50
|
||||
public-domain Project Gutenberg books only rank them by how often they are written in lower case.
|
||||
No other word list or dictionary was used. Regenerate with
|
||||
`cargo run -p craft-proof --example en_core -- <en-us.dic> 12000 crates/proof/data/en-core.txt <books…>`.
|
||||
`data/en-words.dic` is public domain: the Moby Hyphenator II list by Grady Ward (Project Gutenberg
|
||||
eBook #3204), from craft-i18n's `language-data/en-us/en-us.dic` with its break points dropped.
|
||||
Regenerate with
|
||||
`cargo run -p craft-proof --example en_words -- <craft-i18n>/language-data/en-us/en-us.dic crates/proof/data/en-words.dic`.
|
||||
`data/en-extra.txt` is WordCraft contributors' own list (MIT OR Apache-2.0); keep it in step with
|
||||
craft-i18n's copy.
|
||||
|
||||
## Check
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,241 @@
|
||||
# English spelling supplement: common words the public-domain Moby list (1990s) lacks.
|
||||
# Written by WordCraft contributors (MIT OR Apache-2.0); see ATTRIBUTION.md.
|
||||
# One lowercase base form per line, sorted. Plurals, -ed, -ing, -er, -ly… come from the
|
||||
# inflection rules in crates/proof/src/lib.rs, so list "desktop", not "desktops".
|
||||
airbag
|
||||
airfare
|
||||
artcraft
|
||||
autocomplete
|
||||
autocorrect
|
||||
autofill
|
||||
autoformat
|
||||
autosave
|
||||
backend
|
||||
bitcoin
|
||||
bitmap
|
||||
blockchain
|
||||
blog
|
||||
blogger
|
||||
bluetooth
|
||||
captcha
|
||||
caregiver
|
||||
carpool
|
||||
cellphone
|
||||
chatbot
|
||||
chatroom
|
||||
checkbox
|
||||
checklist
|
||||
childcare
|
||||
clamour
|
||||
clickable
|
||||
colourful
|
||||
colourisation
|
||||
colourise
|
||||
colourless
|
||||
configurable
|
||||
copyedit
|
||||
coronavirus
|
||||
covid
|
||||
crowdfunding
|
||||
crowdsource
|
||||
crypto
|
||||
cryptocurrency
|
||||
customizable
|
||||
customization
|
||||
cyber
|
||||
cybersecurity
|
||||
cyberspace
|
||||
database
|
||||
dataset
|
||||
daycare
|
||||
desktop
|
||||
download
|
||||
downloadable
|
||||
downsize
|
||||
dropdown
|
||||
earbud
|
||||
ebook
|
||||
ecommerce
|
||||
email
|
||||
emoji
|
||||
emoticon
|
||||
endeavour
|
||||
esports
|
||||
facebook
|
||||
favicon
|
||||
fintech
|
||||
firewall
|
||||
flowchart
|
||||
foodie
|
||||
freeware
|
||||
frontend
|
||||
fundraiser
|
||||
gameplay
|
||||
gamification
|
||||
geeky
|
||||
geolocation
|
||||
gigabit
|
||||
gigabyte
|
||||
github
|
||||
glamour
|
||||
globalization
|
||||
google
|
||||
groupthink
|
||||
hackathon
|
||||
hardcode
|
||||
hashtag
|
||||
healthcare
|
||||
homepage
|
||||
homeschool
|
||||
honour
|
||||
honourable
|
||||
honourably
|
||||
hotspot
|
||||
hyperlink
|
||||
hypertext
|
||||
inbox
|
||||
incentivize
|
||||
infographic
|
||||
instagram
|
||||
internet
|
||||
interoperability
|
||||
intranet
|
||||
ipad
|
||||
iphone
|
||||
javascript
|
||||
kickstart
|
||||
kilobyte
|
||||
laptop
|
||||
lifestyle
|
||||
linkedin
|
||||
linux
|
||||
livestream
|
||||
lockdown
|
||||
login
|
||||
logout
|
||||
lowercase
|
||||
makeover
|
||||
malware
|
||||
manoeuvrable
|
||||
megabyte
|
||||
megapixel
|
||||
meme
|
||||
metadata
|
||||
microchip
|
||||
microsoft
|
||||
middleware
|
||||
mindset
|
||||
multicultural
|
||||
multiplayer
|
||||
multitask
|
||||
nerdy
|
||||
netbook
|
||||
netflix
|
||||
newsfeed
|
||||
offboard
|
||||
offline
|
||||
onboard
|
||||
online
|
||||
onscreen
|
||||
outsource
|
||||
paediatric
|
||||
paralegal
|
||||
passcode
|
||||
petabyte
|
||||
phishing
|
||||
photobomb
|
||||
playdate
|
||||
playlist
|
||||
plugin
|
||||
podcast
|
||||
popup
|
||||
prequel
|
||||
printout
|
||||
prioritise
|
||||
prioritization
|
||||
prioritize
|
||||
privatise
|
||||
ransomware
|
||||
recyclable
|
||||
rigour
|
||||
ringtone
|
||||
roadmap
|
||||
rollout
|
||||
runtime
|
||||
scalability
|
||||
screencast
|
||||
screensaver
|
||||
screenshot
|
||||
scrollbar
|
||||
selfie
|
||||
signup
|
||||
sitemap
|
||||
skillset
|
||||
smartphone
|
||||
smartwatch
|
||||
smiley
|
||||
snowboard
|
||||
spellcheck
|
||||
spellchecker
|
||||
spreadsheet
|
||||
spyware
|
||||
stakeholder
|
||||
standup
|
||||
startup
|
||||
subfolder
|
||||
sunscreen
|
||||
superfood
|
||||
superuser
|
||||
synthesise
|
||||
tagline
|
||||
takeout
|
||||
techie
|
||||
telecommute
|
||||
teleconference
|
||||
telehealth
|
||||
telemedicine
|
||||
terabyte
|
||||
textbox
|
||||
timeline
|
||||
timestamp
|
||||
toolbar
|
||||
toolkit
|
||||
touchpad
|
||||
touchscreen
|
||||
trackball
|
||||
trackpad
|
||||
travelogue
|
||||
upcycle
|
||||
upload
|
||||
uppercase
|
||||
upsell
|
||||
upskill
|
||||
usability
|
||||
username
|
||||
videoconference
|
||||
videogame
|
||||
vlog
|
||||
vlogger
|
||||
voicemail
|
||||
walkthrough
|
||||
webcam
|
||||
webcast
|
||||
webinar
|
||||
weblog
|
||||
webmaster
|
||||
webpage
|
||||
website
|
||||
whatsapp
|
||||
whiteboard
|
||||
wifi
|
||||
wiki
|
||||
wikipedia
|
||||
wireframe
|
||||
wordcraft
|
||||
workaround
|
||||
workflow
|
||||
workforce
|
||||
workplace
|
||||
workspace
|
||||
workstation
|
||||
youtube
|
||||
Binary file not shown.
@@ -1,70 +0,0 @@
|
||||
//! Regenerate `data/en-core.txt`, the English core word list (see the crate README).
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p craft-proof --example en_core -- <craft-i18n>/language-data/en-us/en-us.dic <count> <out> <book.txt>...
|
||||
//! ```
|
||||
//!
|
||||
//! Every word in the list comes from the public-domain Moby Hyphenator II list (`en-us.dic`).
|
||||
//! The books (public-domain Project Gutenberg plain-text files) only rank those words: a word is
|
||||
//! counted where it is written in lower case (so names, which are capitalized, rank low), must
|
||||
//! appear in at least [`MIN_BOOKS`] of the books, and the `<count>` most frequent are written,
|
||||
//! sorted. No other word list or dictionary is read.
|
||||
//!
|
||||
//! `data/en-core.txt` was made with `<count>` 12000 (11,367 words pass [`MIN_BOOKS`]) from these
|
||||
//! Project Gutenberg eBooks (`https://www.gutenberg.org/cache/epub/<n>/pg<n>.txt`, fetched
|
||||
//! 2026-11-01): 11 16 35 36 43 45 55 74 76 84 98 105 113 120 135 141 158 161 174 215 219 244 345
|
||||
//! 408 514 730 766 768 1023 1080 1184 1228 1260 1342 1400 1497 1661 1952 2542 2554 2600 2701 2814
|
||||
//! 2852 3600 3825 4300 5230 25344 64317.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// A word must occur, in lower case, in at least this many books.
|
||||
const MIN_BOOKS: usize = 8;
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let [dic, count, out, books @ ..] = args.as_slice() else {
|
||||
return Err("usage: en_core <en-us.dic> <count> <out> <book.txt>...".into());
|
||||
};
|
||||
let count: usize = count.parse().map_err(|e| format!("count: {e}"))?;
|
||||
let bytes = std::fs::read(dic).map_err(|e| format!("{dic}: {e}"))?;
|
||||
let moby = craft_proof::dict::Dictionary::from_bytes(&bytes)?;
|
||||
let moby: HashSet<&str> = moby.iter().map(|(w, _)| w).collect();
|
||||
let mut freq: HashMap<String, (u64, usize)> = HashMap::new();
|
||||
for book in books {
|
||||
let text = std::fs::read_to_string(book).map_err(|e| format!("{book}: {e}"))?;
|
||||
// The Project Gutenberg header and licence are not the book.
|
||||
let body = text.split_once("*** START OF").map_or(text.as_str(), |(_, b)| b);
|
||||
let body = body.split_once("*** END OF").map_or(body, |(b, _)| b);
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
for w in body.split(|c: char| !(c.is_ascii_alphabetic() || c == '\'')).map(|w| w.trim_matches('\'')) {
|
||||
if w.len() < 2 || !w.bytes().all(|b| b.is_ascii_lowercase() || b == b'\'') || !moby.contains(w) {
|
||||
continue;
|
||||
}
|
||||
let e = freq.entry(w.to_string()).or_default();
|
||||
e.0 += 1;
|
||||
if seen.insert(w) {
|
||||
e.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ranked: Vec<(String, u64)> = freq.into_iter().filter(|(_, (_, b))| *b >= MIN_BOOKS).map(|(w, (n, _))| (w, n)).collect();
|
||||
ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
ranked.truncate(count);
|
||||
let mut words: Vec<String> = ranked.into_iter().map(|(w, _)| w).collect();
|
||||
words.sort();
|
||||
let mut o = String::from(
|
||||
"# English core word list: the built-in fallback when neither the embedded Language data nor a\n\
|
||||
# system Hunspell English dictionary is available (see crates/proof/README.md).\n\
|
||||
# Public domain: every word is from the Moby Hyphenator II list by Grady Ward (Project\n\
|
||||
# Gutenberg eBook #3204), ranked by frequency in public-domain Project Gutenberg books.\n\
|
||||
# Generated by `cargo run -p craft-proof --example en_core`. Do not edit by hand.\n",
|
||||
);
|
||||
for w in &words {
|
||||
o.push_str(w);
|
||||
o.push('\n');
|
||||
}
|
||||
std::fs::write(out, o).map_err(|e| format!("{out}: {e}"))?;
|
||||
eprintln!("{} words", words.len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Regenerate `data/en-words.dic`, the English word list built into the crate (see the crate
|
||||
//! README): every word of the public-domain Moby Hyphenator II list, without its break points.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p craft-proof --example en_words -- <craft-i18n>/language-data/en-us/en-us.dic data/en-words.dic
|
||||
//! ```
|
||||
//!
|
||||
//! The input is craft-i18n's `en-us.dic`: the Moby Hyphenator II list by Grady Ward (Project
|
||||
//! Gutenberg eBook #3204, `mhyph.txt`), already stripped of the Project Gutenberg header and
|
||||
//! licence, with its break points. The output keeps the words only (in [`Dictionary`]'s format,
|
||||
//! every break mask zero), so it is smaller and carries no hyphenation. No other word list or
|
||||
//! dictionary is read.
|
||||
|
||||
use craft_proof::dict::Dictionary;
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let [dic, out] = args.as_slice() else {
|
||||
return Err("usage: en_words <en-us.dic> <out>".into());
|
||||
};
|
||||
let bytes = std::fs::read(dic).map_err(|e| format!("{dic}: {e}"))?;
|
||||
let moby = Dictionary::from_bytes(&bytes)?;
|
||||
let words = Dictionary::from_entries(moby.iter().map(|(w, _)| (w.to_string(), 0)).collect());
|
||||
let data = words.to_bytes();
|
||||
std::fs::write(out, &data).map_err(|e| format!("{out}: {e}"))?;
|
||||
eprintln!("{} words, {} bytes", words.len(), data.len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -19,7 +19,9 @@
|
||||
//! read as `fi`) and ignored characters (`IGNORE`), and the suggestion tables `TRY`, `KEY`, `MAP`
|
||||
//! and `REP`.
|
||||
//! Morphological data (`AM` aliases, the fields after a stem) is skipped. Rejected: compounding,
|
||||
//! long or numeric flags, `NEEDAFFIX` and the other directives that change which words are correct.
|
||||
//! long or numeric flags, `NEEDAFFIX` and the other directives that change which words are correct
|
||||
//! (an English dictionary's compounding and `OCONV` are ignored instead:
|
||||
//! [`AffixDict::from_hunspell_english`]).
|
||||
//!
|
||||
//! Lookups never panic: stems sit in one string, found through an open-addressing hash table, and
|
||||
//! a probe allocates only small scratch strings for the candidate roots it tries.
|
||||
@@ -339,6 +341,8 @@ struct Rules {
|
||||
substandard: Option<char>,
|
||||
/// The `KEEPCASE` flag.
|
||||
keepcase: Option<char>,
|
||||
/// The `ONLYINCOMPOUND` flag, read for English only ([`AffixDict::from_hunspell_english`]).
|
||||
only_in_compound: Option<char>,
|
||||
iconv: Vec<(String, String)>,
|
||||
ignore: Vec<char>,
|
||||
/// `AF` flag aliases: alias `n` (1-based) stands for `aliases[n - 1]`.
|
||||
@@ -422,8 +426,27 @@ fn zero_empty(s: &str) -> String {
|
||||
/// but it is not a flag of its own: it is dropped wherever flags are read.
|
||||
const EMOJI_SELECTOR: char = '\u{fe0f}';
|
||||
|
||||
/// Read Hunspell `.aff` text (already decoded to UTF-8).
|
||||
fn parse_aff(aff: &str) -> Result<Rules, String> {
|
||||
/// Directives an English dictionary may carry that don't change which English words are correct
|
||||
/// here, ignored by [`AffixDict::from_hunspell_english`]: compounding (English dictionaries use
|
||||
/// it only for ordinals such as `1st`, which have digits and are skipped by the spelling check)
|
||||
/// and output conversion (`OCONV`: how suggestions are written, such as `'` as `’`).
|
||||
const IGNORED_IN_ENGLISH: &[&str] = &[
|
||||
"COMPOUNDBEGIN",
|
||||
"COMPOUNDEND",
|
||||
"COMPOUNDFIRST",
|
||||
"COMPOUNDFLAG",
|
||||
"COMPOUNDLAST",
|
||||
"COMPOUNDMIDDLE",
|
||||
"COMPOUNDMIN",
|
||||
"COMPOUNDPERMITFLAG",
|
||||
"COMPOUNDRULE",
|
||||
"OCONV",
|
||||
];
|
||||
|
||||
/// Read Hunspell `.aff` text (already decoded to UTF-8). `english`: ignore the directives in
|
||||
/// [`IGNORED_IN_ENGLISH`], and keep the `ONLYINCOMPOUND` flag so the stems that are only parts of
|
||||
/// compounds can be left out.
|
||||
fn parse_aff(aff: &str, english: bool) -> Result<Rules, String> {
|
||||
let mut r = Rules::default();
|
||||
let mut cross: HashMap<(bool, char), bool> = HashMap::new();
|
||||
let mut map_header = false;
|
||||
@@ -518,6 +541,8 @@ fn parse_aff(aff: &str) -> Result<Rules, String> {
|
||||
r.prefixes.push(rule);
|
||||
}
|
||||
}
|
||||
"ONLYINCOMPOUND" if english => r.only_in_compound = Some(one_flag(at(1), dir, n)?),
|
||||
d if english && IGNORED_IN_ENGLISH.contains(&d) => {}
|
||||
d if UNSUPPORTED.contains(&d) => return Err(format!("line {}: {d} is not supported", n + 1)),
|
||||
_ => {}
|
||||
}
|
||||
@@ -542,11 +567,23 @@ impl AffixDict {
|
||||
/// Build from Hunspell `.aff` and `.dic` text (decoded to UTF-8; the `.dic` count line and
|
||||
/// morphological fields are ignored). `comments` (provenance, licence) are kept with the data.
|
||||
pub fn from_hunspell(aff: &str, dic: &str, comments: &[&str]) -> Result<AffixDict, String> {
|
||||
AffixDict::from_hunspell_in(aff, dic, comments, false)
|
||||
}
|
||||
|
||||
/// [`AffixDict::from_hunspell`] for an English dictionary: compounding and output conversion
|
||||
/// (`COMPOUNDMIN`, `COMPOUNDRULE`, `OCONV`…), which other languages need but English spelling
|
||||
/// doesn't, are ignored rather than refused, and the stems marked `ONLYINCOMPOUND` (parts of
|
||||
/// compounds only) are left out.
|
||||
pub fn from_hunspell_english(aff: &str, dic: &str, comments: &[&str]) -> Result<AffixDict, String> {
|
||||
AffixDict::from_hunspell_in(aff, dic, comments, true)
|
||||
}
|
||||
|
||||
fn from_hunspell_in(aff: &str, dic: &str, comments: &[&str], english: bool) -> Result<AffixDict, String> {
|
||||
// Some dictionaries open with a UTF-8 BOM; it must not glue itself to the first
|
||||
// directive or turn the `.dic` count line into a stem.
|
||||
let aff = aff.strip_prefix('\u{feff}').unwrap_or(aff);
|
||||
let dic = dic.strip_prefix('\u{feff}').unwrap_or(dic);
|
||||
let rules = parse_aff(aff)?;
|
||||
let rules = parse_aff(aff, english)?;
|
||||
let mut stems: Vec<(String, String)> = Vec::new();
|
||||
for (n, line) in dic.lines().enumerate() {
|
||||
// The entry may hold spaces (`ad hoc/B\tpo:adv`); it ends where Hunspell ends it.
|
||||
@@ -582,6 +619,9 @@ impl AffixDict {
|
||||
continue;
|
||||
}
|
||||
let flags = rules.flags(&flags).to_string();
|
||||
if rules.only_in_compound.is_some_and(|f| flags.contains(f)) {
|
||||
continue;
|
||||
}
|
||||
stems.push((word, flags));
|
||||
}
|
||||
AffixDict::build(rules, stems, comments.iter().map(ToString::to_string).collect())
|
||||
@@ -1756,6 +1796,29 @@ pub(crate) mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// craft-libs#482 review: an English dictionary with compounding (for ordinals) and output
|
||||
/// conversion, as the usual en_US and en_GB ones have, loads for English; its `ONLYINCOMPOUND`
|
||||
/// stems are left out. Other languages still refuse those directives. (A synthetic `.aff`
|
||||
/// written for this test.)
|
||||
#[test]
|
||||
fn english_dictionaries_ignore_compounding_and_output_conversion() {
|
||||
let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwz'\nICONV 1\nICONV ’ '\nOCONV 1\nOCONV ' ’\nNOSUGGEST !\n\
|
||||
COMPOUNDMIN 1\nONLYINCOMPOUND c\nCOMPOUNDRULE 2\nCOMPOUNDRULE n*1t\nCOMPOUNDRULE n*mp\nCOMPOUNDFLAG Z\n\
|
||||
WORDCHARS 0123456789\nSFX S Y 1\nSFX S 0 s [^sxzhy]\nSFX D Y 1\nSFX D 0 ed [^ey]\n";
|
||||
let dic = "5\nhouse/S\nwalk/SD\n0th/pc\n1st/c\nthe\n";
|
||||
assert!(AffixDict::from_hunspell(aff, dic, &[]).is_err(), "refused for other languages");
|
||||
let d = AffixDict::from_hunspell_english(aff, dic, &[]).unwrap();
|
||||
for w in ["house", "houses", "walked", "walks", "the"] {
|
||||
assert!(d.check(w), "{w}");
|
||||
}
|
||||
assert!(!d.check("0th") && !d.check("1st"), "parts of compounds only");
|
||||
assert!(!d.check("housed") && !d.check("hous"));
|
||||
// The directives that change which words are right are still refused.
|
||||
for dir in ["NEEDAFFIX", "CIRCUMFIX", "PSEUDOROOT", "FULLSTRIP"] {
|
||||
assert!(AffixDict::from_hunspell_english(&format!("{dir} X\n"), dic, &[]).is_err(), "{dir}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_and_broken_input_is_an_error() {
|
||||
assert!(AffixDict::from_hunspell("FLAG long\n", DIC, &[]).is_err());
|
||||
|
||||
+271
-125
@@ -5,27 +5,43 @@
|
||||
//! 2. **System Hunspell**: an English Hunspell `.aff`/`.dic` pair found by [`crate::hunspell`]
|
||||
//! (the dictionaries folder first, then the system locations; none on the web). The run's
|
||||
//! English is tried first (`en_US`, `en_GB`, `en_CA`…), then the other Englishes: a full
|
||||
//! dictionary in the other spelling marks fewer right words than the core list.
|
||||
//! 3. **Core list**: [`CORE_LIST`], about 11,000 common words compiled into the crate (with the
|
||||
//! usual inflections). A word it doesn't know is marked only when it is a near miss of a
|
||||
//! common word (`teh`, `recieve`, `definately`); any other word it doesn't know (a rare word, a
|
||||
//! name, a term) is taken as correct, as it may well be right.
|
||||
//! dictionary in the other spelling marks fewer right words than none. So British and Canadian
|
||||
//! English use an `en_US` dictionary when no regional one is installed (and American English
|
||||
//! an `en_GB` one). A file that can't be read is skipped for the next name or folder, and one
|
||||
//! file is read once for every English that uses it. Directives that only matter for other
|
||||
//! languages (compounding, output conversion) are ignored ([`crate::hunspell::load_english`]).
|
||||
//! 3. **Built-in word list**: [`WORDS`], the whole public-domain Moby word list compiled into
|
||||
//! the crate (without its break points), with WordCraft's own supplement of modern words
|
||||
//! ([`EXTRA`]) and the usual inflections. A word it doesn't know is misspelled, as with the
|
||||
//! embedded list.
|
||||
//!
|
||||
//! A Hunspell dictionary that is there but can't be read (unsupported encoding, too large, a
|
||||
//! parse error, no words) is skipped for the core list, and its reason is reported by
|
||||
//! parse error, no words) is skipped, and its reason is reported by
|
||||
//! [`crate::dictionary_load_error`]. Non-English text is never checked against any of these.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock, RwLock};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock, PoisonError, RwLock};
|
||||
|
||||
use crate::affix::AffixDict;
|
||||
use crate::dict::Dictionary;
|
||||
use crate::{Options, ProofLang};
|
||||
|
||||
/// The English core word list (`data/en-core.txt`): the most common words of the public-domain
|
||||
/// Moby Hyphenator II list by Grady Ward, ranked by their frequency in public-domain Project
|
||||
/// Gutenberg books (generated by `examples/en_core.rs`). One lowercase word per line, `#`
|
||||
/// comments.
|
||||
const CORE_LIST: &str = include_str!("../data/en-core.txt");
|
||||
/// The English word list built into the crate (`data/en-words.dic`, [`Dictionary`]'s format with
|
||||
/// no break points): every word of the public-domain Moby Hyphenator II list by Grady Ward
|
||||
/// (generated by `examples/en_words.rs` from craft-i18n's `en-us.dic`).
|
||||
const WORDS: &[u8] = include_bytes!("../data/en-words.dic");
|
||||
|
||||
/// WordCraft's supplement of common modern words the Moby list lacks (`data/en-extra.txt`, a copy
|
||||
/// of craft-i18n's `language-data/en-us/en-extra.txt`, MIT OR Apache-2.0): one lowercase base
|
||||
/// form per line, `#` comments. Used when the Language data doesn't embed its own copy.
|
||||
pub(crate) const EXTRA: &str = include_str!("../data/en-extra.txt");
|
||||
|
||||
/// Longest word (in characters) suggestions are looked for: longer ones get none, which keeps
|
||||
/// the edits tried per word bounded.
|
||||
const MAX_SUGGEST_CHARS: usize = 32;
|
||||
|
||||
/// Most Hunspell `.aff`/`.dic` pairs tried for one English before giving up on Hunspell.
|
||||
const MAX_CANDIDATES: usize = 8;
|
||||
|
||||
/// Where English spelling comes from now ([`crate::english_source`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -34,19 +50,25 @@ pub enum EnglishSource {
|
||||
Embedded,
|
||||
/// An English Hunspell dictionary from the dictionaries folder or the system: its `.dic` file.
|
||||
Hunspell(PathBuf),
|
||||
/// The core word list built into the crate: only near misses of common words are marked.
|
||||
/// The Moby word list built into the crate: a word it doesn't know is misspelled.
|
||||
CoreList,
|
||||
}
|
||||
|
||||
/// The words of [`CORE_LIST`].
|
||||
fn core() -> &'static std::collections::HashSet<&'static str> {
|
||||
static C: OnceLock<std::collections::HashSet<&'static str>> = OnceLock::new();
|
||||
C.get_or_init(|| CORE_LIST.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).collect())
|
||||
/// The words of [`WORDS`] (decoded on first use; empty if they fail to decode, which a test rules
|
||||
/// out).
|
||||
fn words() -> &'static Dictionary {
|
||||
static W: OnceLock<Dictionary> = OnceLock::new();
|
||||
W.get_or_init(|| {
|
||||
Dictionary::from_bytes(WORDS).unwrap_or_else(|e| {
|
||||
log::error!("built-in English word list: {e}");
|
||||
Dictionary::default()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `w` (lower case) a word of the core list?
|
||||
pub(crate) fn core_contains(w: &str) -> bool {
|
||||
core().contains(w)
|
||||
/// Is `w` (lower case) a word of the built-in list?
|
||||
pub(crate) fn word_list_contains(w: &str) -> bool {
|
||||
words().get(w).is_some()
|
||||
}
|
||||
|
||||
/// Hunspell file names to look for, the run's English first.
|
||||
@@ -58,7 +80,7 @@ fn names(lang: ProofLang) -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot of each English in [`HUNSPELL`].
|
||||
/// The slot of each English in [`State::slots`].
|
||||
fn slot(lang: ProofLang) -> usize {
|
||||
match lang {
|
||||
ProofLang::EnGb => 1,
|
||||
@@ -70,8 +92,23 @@ fn slot(lang: ProofLang) -> usize {
|
||||
/// A loaded Hunspell dictionary and its `.dic` file; `None` when none could be loaded.
|
||||
type Loaded = Option<(Arc<AffixDict>, PathBuf)>;
|
||||
|
||||
/// Per English (see [`slot`]): `None` until first use, then what was found.
|
||||
static HUNSPELL: RwLock<[Option<Loaded>; 3]> = RwLock::new([None, None, None]);
|
||||
/// The English Hunspell dictionaries, under one lock.
|
||||
struct State {
|
||||
/// Per English (see [`slot`]): `None` until first use, then what was found.
|
||||
slots: [Option<Loaded>; 3],
|
||||
/// Every `.dic` file read, so Englishes that use the same file share one copy.
|
||||
files: Vec<(PathBuf, Arc<AffixDict>)>,
|
||||
/// Counts reloads, so a load that started before one is not kept.
|
||||
reloads: u64,
|
||||
}
|
||||
|
||||
impl State {
|
||||
const fn new() -> Self {
|
||||
State { slots: [None, None, None], files: Vec::new(), reloads: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
static HUNSPELL: RwLock<State> = RwLock::new(State::new());
|
||||
|
||||
/// Why the embedded word list failed to decode, if it did.
|
||||
static EMBEDDED_ERROR: OnceLock<String> = OnceLock::new();
|
||||
@@ -79,51 +116,117 @@ static EMBEDDED_ERROR: OnceLock<String> = OnceLock::new();
|
||||
/// Remember that the embedded word list is there but failed to decode (English then uses the
|
||||
/// next source).
|
||||
pub(crate) fn note_embedded_error(e: String) {
|
||||
log::error!("embedded English word list: {e}; English spelling falls back to Hunspell or the core word list");
|
||||
log::error!("embedded English word list: {e}; English spelling falls back to Hunspell or the built-in word list");
|
||||
let _ = EMBEDDED_ERROR.set(e);
|
||||
}
|
||||
|
||||
/// The English Hunspell dictionary for `lang` (read on first use).
|
||||
fn hunspell(lang: ProofLang) -> Loaded {
|
||||
let i = slot(lang);
|
||||
if let Some(Some(found)) = HUNSPELL.read().unwrap_or_else(std::sync::PoisonError::into_inner).get(i) {
|
||||
return found.clone();
|
||||
let reloads = {
|
||||
let s = HUNSPELL.read().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(Some(found)) = s.slots.get(i) {
|
||||
return found.clone();
|
||||
}
|
||||
s.reloads
|
||||
};
|
||||
keep(&HUNSPELL, i, reloads, load(lang))
|
||||
}
|
||||
|
||||
/// Keep in `state` what the load of slot `i`, started after `reloads` reloads, found. If the
|
||||
/// folder was reloaded since, the load may have read the old folder: it serves this one check and
|
||||
/// the next one loads again.
|
||||
fn keep(state: &RwLock<State>, i: usize, reloads: u64, loaded: Loaded) -> Loaded {
|
||||
let mut s = state.write().unwrap_or_else(PoisonError::into_inner);
|
||||
if s.reloads != reloads {
|
||||
return match s.slots.get(i) {
|
||||
Some(Some(found)) => found.clone(),
|
||||
_ => loaded,
|
||||
};
|
||||
}
|
||||
let loaded = load(lang);
|
||||
let mut slots = HUNSPELL.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let loaded = loaded.map(|(d, dic)| {
|
||||
// Another English may have read the same file meanwhile: share its copy.
|
||||
let d = match s.files.iter().find(|(p, _)| *p == dic) {
|
||||
Some((_, shared)) => shared.clone(),
|
||||
None => {
|
||||
s.files.push((dic.clone(), d.clone()));
|
||||
d
|
||||
}
|
||||
};
|
||||
(d, dic)
|
||||
});
|
||||
// Another thread may have filled it meanwhile: that one wins.
|
||||
match slots.get_mut(i) {
|
||||
Some(s) => s.get_or_insert(loaded).clone(),
|
||||
match s.slots.get_mut(i) {
|
||||
Some(slot) => slot.get_or_insert(loaded).clone(),
|
||||
None => loaded,
|
||||
}
|
||||
}
|
||||
|
||||
/// The dictionary of the `.dic` file `dic` in `state`, if an English already read it.
|
||||
fn already_read(state: &RwLock<State>, dic: &Path) -> Option<Arc<AffixDict>> {
|
||||
state.read().unwrap_or_else(PoisonError::into_inner).files.iter().find(|(p, _)| p == dic).map(|(_, d)| d.clone())
|
||||
}
|
||||
|
||||
/// Forget what `state` holds, so the next check looks again.
|
||||
fn forget(state: &RwLock<State>) {
|
||||
let mut s = state.write().unwrap_or_else(PoisonError::into_inner);
|
||||
s.slots = [None, None, None];
|
||||
s.files.clear();
|
||||
s.reloads = s.reloads.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// The key load errors of `lang` are kept under ([`crate::hunspell::load_error`]).
|
||||
fn key(lang: ProofLang) -> &'static str {
|
||||
names(lang).first().copied().unwrap_or("en_US")
|
||||
}
|
||||
|
||||
/// Read the first English Hunspell pair for `lang` that loads, trying each name in each folder
|
||||
/// ([`crate::hunspell::candidates`]). The first failure before it (the file `lang` would rather
|
||||
/// use) is reported for `lang`.
|
||||
fn load(lang: ProofLang) -> Loaded {
|
||||
let key = key(lang);
|
||||
let Some((aff, dic)) = crate::hunspell::find(names(lang)) else {
|
||||
let candidates = crate::hunspell::candidates(names(lang));
|
||||
if candidates.is_empty() {
|
||||
crate::hunspell::clear_load_error(key);
|
||||
log::info!("English spelling: no Hunspell English dictionary installed; using the core word list");
|
||||
log::info!("English spelling: no Hunspell English dictionary installed; using the built-in word list");
|
||||
return None;
|
||||
};
|
||||
let loaded = crate::hunspell::load(&aff, &dic).and_then(|d| if d.is_empty() { Err(format!("{}: no words", dic.display())) } else { Ok(d) });
|
||||
match crate::hunspell::note_load(key, &dic, loaded) {
|
||||
Ok(d) => Some((Arc::new(d), dic)),
|
||||
Err(e) => {
|
||||
log::warn!("English spelling: {e}; using the core word list");
|
||||
None
|
||||
}
|
||||
let mut first_error: Option<(PathBuf, String)> = None;
|
||||
let mut found = None;
|
||||
for (aff, dic) in candidates.into_iter().take(MAX_CANDIDATES) {
|
||||
let loaded = match already_read(&HUNSPELL, &dic) {
|
||||
Some(d) => Ok(d),
|
||||
None => crate::hunspell::load_english(&aff, &dic)
|
||||
.and_then(|d| if d.is_empty() { Err(format!("{}: no words", dic.display())) } else { Ok(d) })
|
||||
.map(Arc::new),
|
||||
};
|
||||
match loaded {
|
||||
Ok(d) => {
|
||||
found = Some((d, dic));
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("English spelling: {e}; trying the next dictionary");
|
||||
first_error.get_or_insert((dic, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
match first_error {
|
||||
Some((dic, e)) => {
|
||||
let _ = crate::hunspell::note_load::<()>(key, &dic, Err(e));
|
||||
}
|
||||
None => crate::hunspell::clear_load_error(key),
|
||||
}
|
||||
if found.is_none() {
|
||||
log::warn!("English spelling: no English Hunspell dictionary could be read; using the built-in word list");
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Forget the loaded dictionaries, so the next check looks for them again (the dictionaries
|
||||
/// folder changed).
|
||||
pub(crate) fn reload() {
|
||||
*HUNSPELL.write().unwrap_or_else(std::sync::PoisonError::into_inner) = [None, None, None];
|
||||
forget(&HUNSPELL);
|
||||
}
|
||||
|
||||
/// Where the spelling of `lang` (an English) comes from now. Reads the Hunspell dictionary once
|
||||
@@ -149,7 +252,7 @@ pub(crate) fn load_error(lang: ProofLang) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Is `word` spelled right in `lang` (an English) when no word list is embedded: against the
|
||||
/// Hunspell dictionary, or else the core list.
|
||||
/// Hunspell dictionary, or else the built-in word list.
|
||||
pub(crate) fn is_correct(word: &str, lang: ProofLang, opts: &Options) -> bool {
|
||||
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’').trim_matches(['\'', '’']);
|
||||
if crate::accepted_by_options(w, opts) || !w.chars().any(|c| c.is_ascii_alphabetic()) {
|
||||
@@ -158,7 +261,7 @@ pub(crate) fn is_correct(word: &str, lang: ProofLang, opts: &Options) -> bool {
|
||||
let w = w.replace('’', "'");
|
||||
match hunspell(lang) {
|
||||
Some((d, _)) => hunspell_correct(&d, &w),
|
||||
None => core_correct(&w),
|
||||
None => list_correct(&w),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,67 +271,75 @@ fn hunspell_correct(d: &AffixDict, w: &str) -> bool {
|
||||
known(w) || (w.contains('-') && w.split('-').filter(|p| !p.is_empty()).all(known))
|
||||
}
|
||||
|
||||
/// Against the core list: known words and their inflections are right, and so is any other word
|
||||
/// that isn't a near miss of a common one ([`near_miss`]).
|
||||
fn core_correct(w: &str) -> bool {
|
||||
let capitalized = w.chars().next().is_some_and(char::is_uppercase);
|
||||
let lower = w.to_lowercase();
|
||||
let ok = |p: &str| crate::known_in_core_list(p) || !near_miss(p, capitalized);
|
||||
if lower.contains('-') { crate::known_in_core_list(&lower) || lower.split('-').filter(|p| !p.is_empty()).all(ok) } else { ok(&lower) }
|
||||
}
|
||||
|
||||
/// Is `lower` (a word the core list doesn't know) an obvious misspelling of a common word? Two
|
||||
/// neighbouring letters swapped (`teh`, `Hte`, `recieved`) always is. Otherwise, for lower-case
|
||||
/// words of four letters or more, one letter added, missing or changed (`definately`,
|
||||
/// `ocurred`). Shorter words and capitalized ones (names) are left alone: too many right ones are
|
||||
/// one letter from a common word.
|
||||
fn near_miss(lower: &str, capitalized: bool) -> bool {
|
||||
let target: Vec<char> = lower.chars().collect();
|
||||
let n = target.len();
|
||||
if !(3..=40).contains(&n) {
|
||||
return false;
|
||||
}
|
||||
if core().iter().any(|c| is_swap(&target, c)) {
|
||||
/// Against the built-in word list: its words and their inflections, or each part of a
|
||||
/// hyphenated word. Anything else is misspelled. (Should the list fail to decode, nothing is
|
||||
/// marked rather than everything.)
|
||||
fn list_correct(w: &str) -> bool {
|
||||
if words().is_empty() {
|
||||
return true;
|
||||
}
|
||||
if capitalized || n < 4 {
|
||||
return false;
|
||||
}
|
||||
core().iter().any(|c| {
|
||||
let m = c.chars().count();
|
||||
m + 1 >= n && m <= n + 1 && crate::damerau(&target, c) <= 1
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `b` `a` with two neighbouring letters swapped?
|
||||
fn is_swap(a: &[char], b: &str) -> bool {
|
||||
if b.len() < a.len() || b.chars().count() != a.len() {
|
||||
return false;
|
||||
}
|
||||
let diff: Vec<(usize, char)> = b.chars().enumerate().filter(|&(i, c)| a.get(i) != Some(&c)).collect();
|
||||
match diff.as_slice() {
|
||||
[(i, x), (j, y)] => *j == i + 1 && a.get(*i) == Some(y) && a.get(*j) == Some(x),
|
||||
_ => false,
|
||||
}
|
||||
let lower = w.to_lowercase();
|
||||
crate::known_in_word_list(&lower) || (lower.contains('-') && lower.split('-').filter(|p| !p.is_empty()).all(crate::known_in_word_list))
|
||||
}
|
||||
|
||||
/// Spelling suggestions in `lang` (an English) when no word list is embedded: from the Hunspell
|
||||
/// dictionary, or else the core list.
|
||||
/// dictionary, or else the built-in word list.
|
||||
pub(crate) fn suggest(word: &str, max: usize, lang: ProofLang) -> Vec<String> {
|
||||
match hunspell(lang) {
|
||||
Some((d, _)) => crate::cs::suggest::suggest_in(&d, word, max),
|
||||
None => core_suggest(word, max),
|
||||
None => list_suggest(word, max),
|
||||
}
|
||||
}
|
||||
|
||||
/// Suggestions from the core list: by edit distance ([`crate::suggest_from`]), two swapped
|
||||
/// letters first (`teh` → `the` before `tea`).
|
||||
fn core_suggest(word: &str, max: usize) -> Vec<String> {
|
||||
let target: Vec<char> = word.to_lowercase().chars().collect();
|
||||
let mut out = crate::suggest_from(word, max.saturating_add(4), core().iter().copied());
|
||||
out.sort_by_key(|s| !is_swap(&target, &s.to_lowercase()));
|
||||
out.truncate(max);
|
||||
out
|
||||
/// Letters an edit may add or put in a word's place.
|
||||
const ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz'";
|
||||
|
||||
/// Suggestions from the built-in word list: the words one edit away (two neighbouring letters
|
||||
/// swapped, one letter missing, added or changed), looked up in the list rather than compared
|
||||
/// with every word, so the cost is bounded by the word's length (at most
|
||||
/// [`MAX_SUGGEST_CHARS`]). Swaps first (`hte` → `the` and `het` before `hate`), then words with the same
|
||||
/// first letter, then the nearest in length.
|
||||
fn list_suggest(word: &str, max: usize) -> Vec<String> {
|
||||
let lower = word.to_lowercase().replace('’', "'");
|
||||
let target: Vec<char> = lower.chars().collect();
|
||||
let n = target.len();
|
||||
if max == 0 || n == 0 || n > MAX_SUGGEST_CHARS {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut found: Vec<(bool, bool, usize, String)> = Vec::new();
|
||||
let mut tried = std::collections::HashSet::new();
|
||||
let mut consider = |c: Vec<char>, swap: bool| {
|
||||
let s: String = c.iter().collect();
|
||||
if s != lower && tried.insert(s.clone()) && crate::known_in_word_list(&s) {
|
||||
found.push((!swap, c.first() != target.first(), c.len().abs_diff(n), s));
|
||||
}
|
||||
};
|
||||
for i in 1..n {
|
||||
let mut c = target.clone();
|
||||
c.swap(i - 1, i);
|
||||
consider(c, true);
|
||||
}
|
||||
for i in 0..n {
|
||||
let mut c = target.clone();
|
||||
c.remove(i);
|
||||
consider(c, false);
|
||||
}
|
||||
for i in 0..=n {
|
||||
for a in ALPHABET.chars() {
|
||||
if i < n {
|
||||
let mut c = target.clone();
|
||||
if let Some(l) = c.get_mut(i) {
|
||||
*l = a;
|
||||
}
|
||||
consider(c, false);
|
||||
}
|
||||
let mut c = target.clone();
|
||||
c.insert(i, a);
|
||||
consider(c, false);
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
found.into_iter().take(max).map(|(_, _, _, w)| crate::match_case(word, w)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -236,24 +347,20 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn core_list_is_small_sorted_lowercase_and_has_the_common_words() {
|
||||
assert!(CORE_LIST.len() < 100 * 1024, "{} bytes", CORE_LIST.len());
|
||||
let words: Vec<&str> = CORE_LIST.lines().filter(|l| !l.starts_with('#')).collect();
|
||||
assert!(words.len() > 5_000, "{}", words.len());
|
||||
assert!(words.windows(2).all(|w| w[0] < w[1]), "sorted, no repeats");
|
||||
assert!(words.iter().all(|w| !w.is_empty() && w.chars().all(|c| c.is_ascii_lowercase() || c == '\'')));
|
||||
for w in ["the", "house", "because", "received", "definitely", "separate", "occurred", "beautiful", "people", "water"] {
|
||||
assert!(core_contains(w), "{w}");
|
||||
}
|
||||
fn built_in_list_is_the_whole_moby_list() {
|
||||
let d = words();
|
||||
assert!(d.len() > 150_000, "{}", d.len());
|
||||
assert!(d.iter().all(|(w, m)| m == 0 && !w.is_empty() && w.chars().all(|c| !c.is_uppercase())));
|
||||
assert!(WORDS.len() < 400 * 1024, "{} bytes", WORDS.len());
|
||||
let extra: Vec<&str> = EXTRA.lines().filter(|l| !l.is_empty() && !l.starts_with('#')).collect();
|
||||
assert!(extra.len() > 100 && extra.windows(2).all(|p| p[0] < p[1]), "the supplement is sorted");
|
||||
}
|
||||
|
||||
/// The core list marks obvious misspellings and leaves right words alone, inflected ones and
|
||||
/// words it doesn't know included.
|
||||
/// The built-in list knows right words, the rare and the modern ones included, and marks
|
||||
/// every word it doesn't know (craft-libs#482 review: the old core list let any unknown word
|
||||
/// through unless it was a near miss of a common one, and marked right words that were).
|
||||
#[test]
|
||||
fn core_list_flags_near_misses_only() {
|
||||
for w in ["teh", "Teh", "hte", "recieved", "definately", "seperate", "ocurred", "becuase", "peopel", "beautifull", "watre"] {
|
||||
assert!(!core_correct(w), "{w} is marked");
|
||||
}
|
||||
fn built_in_list_marks_unknown_words_only() {
|
||||
for w in [
|
||||
"the",
|
||||
"The",
|
||||
@@ -264,31 +371,70 @@ mod tests {
|
||||
"kindness",
|
||||
"Received",
|
||||
"well-known",
|
||||
// Not in the list, but not a near miss of a common word either: left alone.
|
||||
"photosynthesis",
|
||||
"zxqv",
|
||||
"Nguyen",
|
||||
"Jarndyce",
|
||||
"cat",
|
||||
"fiber",
|
||||
"theater",
|
||||
"organize",
|
||||
"budget",
|
||||
"phone",
|
||||
"people's",
|
||||
// WordCraft's supplement.
|
||||
"login",
|
||||
"blog",
|
||||
"email",
|
||||
"emails",
|
||||
] {
|
||||
assert!(core_correct(w), "{w} is not marked");
|
||||
assert!(list_correct(w), "{w} is not marked");
|
||||
}
|
||||
assert!(!core_correct("well-knwon"), "each part of a compound is checked");
|
||||
for w in ["recieved", "definately", "seperate", "ocurred", "becuase", "peopel", "beautifull", "zxqv", "Jarndycey"] {
|
||||
assert!(!list_correct(w), "{w} is marked");
|
||||
}
|
||||
assert!(!list_correct("well-knwon"), "each part of a compound is checked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swaps_are_two_neighbouring_letters() {
|
||||
let a: Vec<char> = "teh".chars().collect();
|
||||
assert!(is_swap(&a, "the"));
|
||||
assert!(!is_swap(&a, "teh") && !is_swap(&a, "ten") && !is_swap(&a, "thee") && !is_swap(&a, "het"));
|
||||
assert!(!is_swap(&"é".chars().collect::<Vec<_>>(), "ab"));
|
||||
fn built_in_list_suggests_one_edit_away() {
|
||||
assert_eq!(list_suggest("becuase", 3).first().map(String::as_str), Some("because"));
|
||||
assert_eq!(list_suggest("Becuase", 3).first().map(String::as_str), Some("Because"));
|
||||
assert!(list_suggest("hte", 3).contains(&"the".to_string()), "{:?}", list_suggest("hte", 3));
|
||||
assert!(list_suggest("definately", 5).contains(&"definitely".to_string()));
|
||||
assert!(list_suggest("recieve", 5).contains(&"receive".to_string()));
|
||||
assert!(list_suggest("", 5).is_empty() && list_suggest("becuase", 0).is_empty());
|
||||
assert!(list_suggest(&"a".repeat(MAX_SUGGEST_CHARS + 1), 5).is_empty());
|
||||
}
|
||||
|
||||
/// Suggestions look words up rather than scanning the list: the longest word is still quick.
|
||||
#[test]
|
||||
fn core_list_suggests_the_common_word() {
|
||||
assert_eq!(core_suggest("teh", 3).first().map(String::as_str), Some("the"));
|
||||
assert_eq!(core_suggest("Teh", 3).first().map(String::as_str), Some("The"));
|
||||
assert!(core_suggest("definately", 5).contains(&"definitely".to_string()));
|
||||
assert!(core_suggest("", 5).is_empty() && core_suggest("teh", 0).is_empty());
|
||||
fn built_in_list_suggestions_are_bounded() {
|
||||
let _ = words();
|
||||
let word: String = "qwertyuiopasdfghjklzxcvbnm".chars().cycle().take(MAX_SUGGEST_CHARS).collect();
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..10 {
|
||||
let _ = list_suggest(&word, 5);
|
||||
}
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
}
|
||||
|
||||
/// craft-libs#482 review: a load that started before a reload (the dictionaries folder
|
||||
/// changed) serves its own check but isn't kept, so the next check reads the new folder; a
|
||||
/// load after it is kept, and shared with the Englishes that read the same file.
|
||||
#[test]
|
||||
fn a_load_from_before_a_reload_is_not_kept() {
|
||||
let state = RwLock::new(State::new());
|
||||
let dict = || Arc::new(AffixDict::from_hunspell("", "1\nzzyzx\n", &[]).unwrap());
|
||||
let file = PathBuf::from("/old/folder/en_US.dic");
|
||||
let before = state.read().unwrap().reloads;
|
||||
forget(&state); // The folder changes while the load runs.
|
||||
let got = keep(&state, slot(ProofLang::En), before, Some((dict(), file.clone())));
|
||||
assert_eq!(got.map(|(_, p)| p), Some(file.clone()), "the stale load still answers its own check");
|
||||
assert!(state.read().unwrap().slots.iter().all(Option::is_none), "but it isn't kept");
|
||||
assert!(already_read(&state, &file).is_none());
|
||||
let now = state.read().unwrap().reloads;
|
||||
let a = keep(&state, slot(ProofLang::En), now, Some((dict(), file.clone()))).unwrap();
|
||||
let b = keep(&state, slot(ProofLang::EnGb), now, Some((dict(), file.clone()))).unwrap();
|
||||
assert!(Arc::ptr_eq(&a.0, &b.0), "one copy of a file for every English that reads it");
|
||||
assert!(already_read(&state, &file).is_some());
|
||||
forget(&state);
|
||||
assert!(already_read(&state, &file).is_none() && state.read().unwrap().slots.iter().all(Option::is_none));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,24 @@ pub fn find(names: &[&str]) -> Option<(PathBuf, PathBuf)> {
|
||||
search_dirs(lo.as_deref()).into_iter().find_map(|dir| find_in(&dir, names))
|
||||
}
|
||||
|
||||
/// Every `<name>.aff`/`<name>.dic` pair for `names`, in the order [`find`] prefers them: folder
|
||||
/// by folder ([`search_dirs`]), each folder's in the order of `names`. A caller that can't read
|
||||
/// one tries the next.
|
||||
pub fn candidates(names: &[&str]) -> Vec<(PathBuf, PathBuf)> {
|
||||
let lo = names.first().copied().map(lo_ext_dirs);
|
||||
let mut out: Vec<(PathBuf, PathBuf)> = Vec::new();
|
||||
for dir in search_dirs(lo.as_deref()) {
|
||||
for n in names {
|
||||
let aff = dir.join(format!("{n}.aff"));
|
||||
let dic = dir.join(format!("{n}.dic"));
|
||||
if aff.is_file() && dic.is_file() && !out.iter().any(|(_, d)| *d == dic) {
|
||||
out.push((aff, dic));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The first `<name>.aff`/`<name>.dic` pair for any of `names` in `dir`.
|
||||
pub fn find_in(dir: &Path, names: &[&str]) -> Option<(PathBuf, PathBuf)> {
|
||||
names.iter().find_map(|n| {
|
||||
@@ -183,6 +201,15 @@ pub fn load(aff: &Path, dic: &Path) -> Result<AffixDict, String> {
|
||||
AffixDict::from_hunspell(&aff_text, &dic_text, &[source.as_str()])
|
||||
}
|
||||
|
||||
/// [`load`] for an English dictionary: directives that only matter for other languages
|
||||
/// (compounding, output conversion) are ignored rather than refused
|
||||
/// ([`AffixDict::from_hunspell_english`]).
|
||||
pub fn load_english(aff: &Path, dic: &Path) -> Result<AffixDict, String> {
|
||||
let (aff_text, dic_text) = decode(&read_capped(aff)?, &read_capped(dic)?)?;
|
||||
let source = format!("Hunspell dictionary {}", dic.display());
|
||||
AffixDict::from_hunspell_english(&aff_text, &dic_text, &[source.as_str()])
|
||||
}
|
||||
|
||||
/// ISO 8859-1: every byte is the code point of the same value (the original VERO pt_BR uses it).
|
||||
fn decode_latin1(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|&b| char::from(b)).collect()
|
||||
|
||||
+65
-26
@@ -51,8 +51,8 @@
|
||||
//! **Language data** (the Moby word list, the pattern files, our word supplements) is embedded at
|
||||
//! build time from craft-i18n's `language-data/` (`CRAFT_I18N_DIR`, see `build.rs`). English
|
||||
//! spelling never goes unchecked: without the embedded word list it uses an English Hunspell
|
||||
//! dictionary installed on the system, and without one the core word list built into the crate
|
||||
//! (see [`english_source`]). Hyphenation that needs the data is off without it; grammar and the
|
||||
//! dictionary installed on the system, and without one the whole Moby word list built into the
|
||||
//! crate (see [`english_source`]). Hyphenation that needs the data is off without it; grammar and the
|
||||
//! rule-based hyphenation (Bulgarian, Turkish, Russian) are code and still work.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -326,8 +326,9 @@ pub fn dictionary_load_error(lang: ProofLang) -> Option<String> {
|
||||
|
||||
/// Where the spelling of an English `lang` comes from now (`None` for other languages): the word
|
||||
/// list embedded at build time, else an English Hunspell dictionary from the dictionaries folder
|
||||
/// or the system (the run's English first), else the core word list built into the crate, which
|
||||
/// marks only near misses of common words. Reads the Hunspell dictionary once when there is no
|
||||
/// or the system (the run's English first), else the Moby word list built into the crate.
|
||||
/// British and Canadian English use an `en_US` Hunspell dictionary when no regional one is
|
||||
/// installed. Reads the Hunspell dictionary once when there is no
|
||||
/// embedded word list (it is cached afterwards).
|
||||
pub fn english_source(lang: ProofLang) -> Option<EnglishSource> {
|
||||
lang.is_english().then(|| english::source(lang))
|
||||
@@ -464,9 +465,11 @@ const COMMON: &str = "a an the and or but if of to in on at by for with from as
|
||||
/// lowercase base form per line, `#` comments. Inflections come from [`known_core`].
|
||||
use data::EXTRA_EN;
|
||||
|
||||
/// The supplement's words: the Language data's copy, else the crate's own ([`english::EXTRA`]).
|
||||
fn extra_words() -> &'static HashSet<&'static str> {
|
||||
static E: OnceLock<HashSet<&str>> = OnceLock::new();
|
||||
E.get_or_init(|| EXTRA_EN.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).collect())
|
||||
let extra = if EXTRA_EN.is_empty() { english::EXTRA } else { EXTRA_EN };
|
||||
E.get_or_init(|| extra.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).collect())
|
||||
}
|
||||
|
||||
/// A base form in the dictionary or the supplement.
|
||||
@@ -479,9 +482,9 @@ fn known_core(w: &str) -> bool {
|
||||
known_with(w, &|s| base_known(d, s))
|
||||
}
|
||||
|
||||
/// [`known_core`] against the core word list built into the crate (no embedded word list).
|
||||
pub(crate) fn known_in_core_list(w: &str) -> bool {
|
||||
known_with(w, &|s| english::core_contains(s) || extra_words().contains(s))
|
||||
/// [`known_core`] against the word list built into the crate (no embedded word list).
|
||||
pub(crate) fn known_in_word_list(w: &str) -> bool {
|
||||
known_with(w, &|s| english::word_list_contains(s) || extra_words().contains(s))
|
||||
}
|
||||
|
||||
/// Is `w` (lower case) a word, by `base_known` (a base form in the word list), the common words,
|
||||
@@ -608,7 +611,7 @@ pub(crate) fn accepted_by_options(word: &str, opts: &Options) -> bool {
|
||||
}
|
||||
|
||||
/// Is the English word list embedded? Without it English is checked against an installed
|
||||
/// Hunspell dictionary or the core word list ([`english`]).
|
||||
/// Hunspell dictionary or the word list built into the crate ([`english`]).
|
||||
pub(crate) fn english_data() -> bool {
|
||||
!dict::Dictionary::en_us().is_empty()
|
||||
}
|
||||
@@ -1196,7 +1199,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Remove the English files of `dir` and set it again: English goes back to what it was
|
||||
/// (the core list, or a dictionary installed on the system), for the tests that follow.
|
||||
/// (the built-in list, or a dictionary installed on the system), for the tests that follow.
|
||||
fn drop_english_files(dir: &std::path::Path, english: &[(&str, &[u8], &[u8])]) {
|
||||
for (name, _, _) in english {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{name}.aff")));
|
||||
@@ -1206,18 +1209,18 @@ mod tests {
|
||||
}
|
||||
|
||||
/// R6.4, English never goes unchecked: with no embedded word list, an English Hunspell
|
||||
/// dictionary (here in the dictionaries folder) is used before the core word list built into
|
||||
/// the crate; with one, the embedded list stays in use whatever is installed.
|
||||
/// dictionary (here in the dictionaries folder) is used before the word list built into the
|
||||
/// crate; with one, the embedded list stays in use whatever is installed.
|
||||
#[test]
|
||||
fn english_hunspell_dictionary_is_used_before_the_core_list() {
|
||||
fn english_hunspell_dictionary_is_used_before_the_built_in_list() {
|
||||
let _guard = DICTIONARY_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let dir = std::env::temp_dir().join(format!("wc-proof-en-hunspell-{}", std::process::id()));
|
||||
// A tiny en_US. `teh` is a word in it, so a right `teh` is this dictionary answering (the
|
||||
// core list marks it); `hous` and `because` aren't.
|
||||
// A tiny en_US. `hous` and `because` aren't words in it, so a marked `because` is this
|
||||
// dictionary answering (the built-in list knows it).
|
||||
let english: &[(&str, &[u8], &[u8])] =
|
||||
&[("en_US", b"SET UTF-8\nTRY esiarntolcdugmphbyfvkwz\nSFX S Y 1\nSFX S 0 s .\n", b"3\nteh\nthe\nhouse/S\n")];
|
||||
folder_with_english(&dir, english);
|
||||
if cfg!(craft_proof_data) {
|
||||
if english_data() {
|
||||
assert_eq!(english_source(ProofLang::En), Some(EnglishSource::Embedded));
|
||||
// (The Moby list has `teh`.)
|
||||
assert!(!is_correct_in("becuase", ProofLang::En) && is_correct_in("because", ProofLang::En));
|
||||
@@ -1229,12 +1232,12 @@ mod tests {
|
||||
assert!(is_correct_in(w, ProofLang::En), "{w}");
|
||||
}
|
||||
for w in ["hous", "because", "housez"] {
|
||||
assert!(!is_correct_in(w, ProofLang::En), "{w}: the dictionary's words, not the core list's");
|
||||
assert!(!is_correct_in(w, ProofLang::En), "{w}: the dictionary's words, not the built-in list's");
|
||||
}
|
||||
assert!(!is_correct("hous") && is_correct("teh"), "the older English-only functions too");
|
||||
assert_eq!(check_spelling_in("teh hous", ProofLang::En).len(), 1);
|
||||
assert!(suggest_in("hous", 3, ProofLang::En).contains(&"house".to_string()), "{:?}", suggest_in("hous", 3, ProofLang::En));
|
||||
// No en_GB or en_CA file: the en_US one beats the core list.
|
||||
// No en_GB or en_CA file: they use the en_US one (one copy, shared).
|
||||
assert_eq!(english_source(ProofLang::EnGb), Some(EnglishSource::Hunspell(dic.clone())));
|
||||
assert_eq!(english_source(ProofLang::EnCa), Some(EnglishSource::Hunspell(dic)));
|
||||
}
|
||||
@@ -1243,14 +1246,15 @@ mod tests {
|
||||
assert!(check_text("hous teh", &[(0..8, ProofLang::De)]).is_empty());
|
||||
assert_eq!(english_source(ProofLang::Pl), None);
|
||||
drop_english_files(&dir, english);
|
||||
if !cfg!(craft_proof_data) {
|
||||
if !english_data() {
|
||||
assert_ne!(english_source(ProofLang::En), Some(EnglishSource::Hunspell(dir.join("en_US.dic"))));
|
||||
}
|
||||
}
|
||||
|
||||
/// R6.4: an English Hunspell dictionary that is there but can't be read (an encoding we don't
|
||||
/// read, bytes that aren't UTF-8, no words) is an error reported for its English, never a
|
||||
/// panic, and that English falls back to the core list, which still marks `becuase`.
|
||||
/// panic, and that English falls back to the next dictionary or the built-in word list, which
|
||||
/// still marks `becuase`.
|
||||
#[test]
|
||||
fn unreadable_english_dictionary_is_reported_and_skipped() {
|
||||
let _guard = DICTIONARY_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
@@ -1259,11 +1263,13 @@ mod tests {
|
||||
&[("en_US", b"SET UTF-8\n", b"0\n"), ("en_GB", b"SET SHIFT_JIS\n", b"1\ncolour\n"), ("en_CA", b"SET UTF-8\n", b"1\n\xff\xfe\n")];
|
||||
folder_with_english(&dir, english);
|
||||
for (lang, reason) in [(ProofLang::En, "no words"), (ProofLang::EnGb, "unsupported encoding"), (ProofLang::EnCa, "not UTF-8")] {
|
||||
if cfg!(craft_proof_data) {
|
||||
if english_data() {
|
||||
assert_eq!(english_source(lang), Some(EnglishSource::Embedded));
|
||||
assert_eq!(dictionary_load_error(lang), None, "{lang:?}: never read");
|
||||
} else {
|
||||
assert_eq!(english_source(lang), Some(EnglishSource::CoreList), "{lang:?}");
|
||||
// The built-in list, or a dictionary installed on the system (tried next).
|
||||
let source = english_source(lang);
|
||||
assert!(!matches!(&source, Some(EnglishSource::Hunspell(p)) if p.starts_with(&dir)), "{lang:?}: {source:?}");
|
||||
let error = dictionary_load_error(lang).unwrap_or_default();
|
||||
assert!(error.contains(reason), "{lang:?}: {error}");
|
||||
}
|
||||
@@ -1274,6 +1280,36 @@ mod tests {
|
||||
assert_eq!(dictionary_load_error(ProofLang::EnGb), None, "a new folder forgets the old errors");
|
||||
}
|
||||
|
||||
/// craft-libs#482 review: an English dictionary that can't be read is skipped for the next
|
||||
/// name or folder, not for the built-in list; and a dictionary with compounding and output
|
||||
/// conversion (as the usual en_US and en_GB ones have) is read, not refused.
|
||||
#[test]
|
||||
fn english_tries_the_next_dictionary_and_reads_real_world_directives() {
|
||||
let _guard = DICTIONARY_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let dir = std::env::temp_dir().join(format!("wc-proof-en-next-{}", std::process::id()));
|
||||
let english: &[(&str, &[u8], &[u8])] = &[
|
||||
("en_GB", b"SET SHIFT_JIS\n", b"1\ncolour\n"),
|
||||
(
|
||||
"en_US",
|
||||
b"SET UTF-8\nICONV 1\nICONV \xe2\x80\x99 '\nOCONV 1\nOCONV ' \xe2\x80\x99\nCOMPOUNDMIN 1\nONLYINCOMPOUND c\n\
|
||||
COMPOUNDRULE 1\nCOMPOUNDRULE n*1t\nSFX S Y 1\nSFX S 0 s .\n",
|
||||
b"4\nhouse/S\nthe\nqwzzy\n1st/c\n",
|
||||
),
|
||||
];
|
||||
folder_with_english(&dir, english);
|
||||
if !english_data() {
|
||||
let us = dir.join("en_US.dic");
|
||||
for lang in [ProofLang::En, ProofLang::EnGb, ProofLang::EnCa] {
|
||||
assert_eq!(english_source(lang), Some(EnglishSource::Hunspell(us.clone())), "{lang:?}");
|
||||
assert!(is_correct_in("qwzzy", lang) && is_correct_in("houses", lang) && !is_correct_in("because", lang), "{lang:?}");
|
||||
}
|
||||
let error = dictionary_load_error(ProofLang::EnGb).unwrap_or_default();
|
||||
assert!(error.contains("unsupported encoding"), "the en_GB file it would rather use is reported: {error}");
|
||||
assert_eq!(dictionary_load_error(ProofLang::En), None);
|
||||
}
|
||||
drop_english_files(&dir, english);
|
||||
}
|
||||
|
||||
/// #1063: the dictionaries folder is searched before the system locations; the dictionaries
|
||||
/// in it are reported and used, and cached checks are dropped. The folder holds the same test
|
||||
/// dictionaries the other tests install, so tests running meanwhile see the same words; it
|
||||
@@ -1718,8 +1754,8 @@ mod tests {
|
||||
assert!(words.iter().all(|w| w.chars().all(|c| c.is_lowercase() || c == '\'' || c == '-')), "not lowercase");
|
||||
}
|
||||
|
||||
/// Without the Language data too: the installed English dictionary or the core list marks
|
||||
/// both near misses (R6.4).
|
||||
/// Without the Language data too: the installed English dictionary or the built-in word list
|
||||
/// marks both misspellings (R6.4).
|
||||
#[test]
|
||||
fn spelling_issues_in_text() {
|
||||
let _guard = DICTIONARY_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
@@ -1758,12 +1794,13 @@ mod tests {
|
||||
assert!(["xyzq", "blorp", "qwirpy", "zxq", "frobq", "zibb", "snorf", "wugwug"].iter().all(|w| bad.contains(w)), "{bad:?}");
|
||||
}
|
||||
|
||||
#[cfg_attr(not(craft_proof_data), ignore = "needs the Language data (CRAFT_I18N_DIR)")]
|
||||
#[test]
|
||||
fn spelling_scales_over_one_long_token() {
|
||||
// #1335: thousands of punctuation-separated words inside a single
|
||||
// whitespace-free token: one classification for the token, one issue per
|
||||
// misspelled word, through every public spelling entry point.
|
||||
// misspelled word, through every public spelling entry point. Without the Language data
|
||||
// too (craft-libs#482 review: the old core list took 14 s here).
|
||||
let started = std::time::Instant::now();
|
||||
let word = |i: usize| {
|
||||
let mut w = String::from("zxq");
|
||||
let mut n = i;
|
||||
@@ -1782,6 +1819,8 @@ mod tests {
|
||||
let opts = Options { ignore_internet: false, ..Options::default() };
|
||||
assert_eq!(check_spelling_with(&t, &opts).len(), 2000);
|
||||
assert_eq!(check_text(&t, &[]).into_iter().filter(|i| i.kind == IssueKind::Spelling).count(), 2000);
|
||||
// Debug builds on a loaded CI machine: a bound against blowups, not a benchmark.
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+12
-10
@@ -1,6 +1,6 @@
|
||||
//! A build with no Language data embedded (no `CRAFT_I18N_DIR`): every public entry point still
|
||||
//! works and never panics. English spelling still marks obvious misspellings, against an English
|
||||
//! Hunspell dictionary installed on the system or else the core word list built into the crate;
|
||||
//! works and never panics. English spelling still marks misspellings, against an English
|
||||
//! Hunspell dictionary installed on the system or else the Moby word list built into the crate;
|
||||
//! the dialect variants and hyphenation that need the data are off, and grammar (code, not data)
|
||||
//! still works.
|
||||
|
||||
@@ -53,23 +53,25 @@ fn nothing_is_embedded() {
|
||||
}
|
||||
|
||||
/// R6.4, English never goes unchecked: without the embedded word list, English is checked against
|
||||
/// an installed English Hunspell dictionary or, without one, the core word list. Either way an
|
||||
/// obvious misspelling is marked and common words are right. (Before, everything was right.)
|
||||
/// an installed English Hunspell dictionary or, without one, the Moby word list built into the
|
||||
/// crate. Either way a misspelling is marked and common words are right. (Before, everything was right.)
|
||||
#[test]
|
||||
fn english_spelling_still_marks_misspellings() {
|
||||
let _guard = crate::DICTIONARY_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for l in [ProofLang::En, ProofLang::EnGb, ProofLang::EnCa] {
|
||||
assert!(matches!(crate::english_source(l), Some(EnglishSource::Hunspell(_) | EnglishSource::CoreList)), "{l:?}");
|
||||
assert!(!crate::is_correct_in("teh", l), "{l:?}");
|
||||
assert!(crate::suggest_in("teh", 5, l).contains(&"the".to_string()), "{l:?}");
|
||||
assert!(!crate::is_correct_in("becuase", l), "{l:?}");
|
||||
assert!(crate::suggest_in("becuase", 5, l).contains(&"because".to_string()), "{l:?}");
|
||||
for w in ["the", "The", "house", "houses", "because", "running", "people", "beautiful", "don't"] {
|
||||
assert!(crate::is_correct_in(w, l), "{w} {l:?}");
|
||||
}
|
||||
assert_eq!(crate::check_spelling_in("Teh house of the people", l).len(), 1, "{l:?}");
|
||||
assert_eq!(crate::check_spelling_in("Becuase the house of the people", l).len(), 1, "{l:?}");
|
||||
}
|
||||
assert!(!crate::is_correct("teh") && !crate::is_correct_with("teh", &Options::default()) && !crate::is_correct_for("teh", Some("en-GB")));
|
||||
assert!(crate::suggest("teh", 5).contains(&"the".to_string()));
|
||||
assert_eq!(crate::check_spelling("Teh house").len(), 1);
|
||||
assert!(
|
||||
!crate::is_correct("becuase") && !crate::is_correct_with("becuase", &Options::default()) && !crate::is_correct_for("becuase", Some("en-GB"))
|
||||
);
|
||||
assert!(crate::suggest("becuase", 5).contains(&"because".to_string()));
|
||||
assert_eq!(crate::check_spelling("Becuase house").len(), 1);
|
||||
// The dialect variants are Language data: none without it.
|
||||
for w in ["colour", "color"] {
|
||||
assert_eq!(en_variants::preferred(w, ProofLang::En), None);
|
||||
|
||||
@@ -83,7 +83,7 @@ mod checker_tests {
|
||||
assert!(check_text_with(text, &[], &Options::default(), true, false).is_empty());
|
||||
}
|
||||
|
||||
/// Without the Language data too: the installed English dictionary or the core list marks
|
||||
/// Without the Language data too: the installed English dictionary or the built-in word list marks
|
||||
/// `yesterdy` (R6.4).
|
||||
#[test]
|
||||
fn english_keeps_its_marks_and_skips_vietnamese_names() {
|
||||
|
||||
Reference in New Issue
Block a user