From 7c891de4502ddb52e9da73d62b80cc0f45434b6c Mon Sep 17 00:00:00 2001 From: Ayoze Torres <53948812+ayozetr@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:01:00 +0000 Subject: [PATCH 1/2] Spell check: don't check words longer than 100 characters (#1175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dictionary::check splits a word at every point looking for compound parts, so a check costs O(n²) in the word's length under a compounding dictionary (German, Dutch, …). A 100,000-character "word" took about 17 s per check in release, and spell check runs again on every edit. suggest() was already capped at 64 characters; check() wasn't. Words longer than MAX_CHECK_CHARS (100, Hunspell's word length limit) now pass unchecked. --- crates/engine/src/spell.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs index 44d1c6b7..ac7d243a 100644 --- a/crates/engine/src/spell.rs +++ b/crates/engine/src/spell.rs @@ -356,7 +356,14 @@ impl Dictionary { } /// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass. + /// Words longer than [`MAX_CHECK_CHARS`] are not checked (they pass). pub fn check(&self, word: &str) -> bool { + // Compound splitting tries every split point, so checking costs O(n²) in the word's + // length: a 100,000-character "word" took seconds per check under a compounding + // dictionary, on every edit (#1175). + if word.chars().nth(MAX_CHECK_CHARS).is_some() { + return true; + } if word.contains('\u{2019}') { return self.check(&word.replace('\u{2019}', "'")); } @@ -553,6 +560,9 @@ pub fn find_in(dirs: &[PathBuf], lang: &str) -> Option<(PathBuf, PathBuf)> { /// Longest word `Dictionary::suggest` generates corrections for. const MAX_SUGGEST_CHARS: usize = 64; +/// Longest word `Dictionary::check` checks, as Hunspell's word length limit. +const MAX_CHECK_CHARS: usize = 100; + type Cache = Mutex>>>; fn cache() -> &'static Cache { @@ -843,6 +853,20 @@ SFX D Y 1\nSFX D 0 ed/S .\n"; assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string())); } + /// #1175: compound splitting is quadratic in the word's length, so a word longer than + /// `MAX_CHECK_CHARS` is not checked; shorter ones still are. + #[test] + fn a_word_longer_than_the_check_limit_is_not_checked() { + let d = Dictionary::parse(b"COMPOUNDFLAG X\nCOMPOUNDMIN 3\n", b"2\nfoot/X\nball/X\n".as_slice()); + assert!(d.check("footballfoot"), "a compound is checked"); + let under: String = "footb".repeat(MAX_CHECK_CHARS / 5); + assert_eq!(under.chars().count(), MAX_CHECK_CHARS); + assert!(!d.check(&under), "a word at the limit is still checked"); + // Unchecked, not seconds of compound splitting. + assert!(d.check(&"footb".repeat(20_000))); + assert!(d.check(&format!("{under}x"))); + } + #[test] fn compounds_join_flagged_parts() { let aff = "COMPOUNDFLAG X\nCOMPOUNDMIN 3\nONLYINCOMPOUND O\nSFX S Y 1\nSFX S 0 en .\n"; From d027f9d866900ad80332fb43ba8d3177b25af2e8 Mon Sep 17 00:00:00 2001 From: Ayoze Torres <53948812+ayozetr@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:13:04 +0000 Subject: [PATCH 2/2] Spell check docs: state the 100-character limit instead of linking a private constant (#1175) --- crates/engine/src/spell.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs index ac7d243a..addb8048 100644 --- a/crates/engine/src/spell.rs +++ b/crates/engine/src/spell.rs @@ -356,7 +356,7 @@ impl Dictionary { } /// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass. - /// Words longer than [`MAX_CHECK_CHARS`] are not checked (they pass). + /// Words longer than 100 characters are not checked (they pass). pub fn check(&self, word: &str) -> bool { // Compound splitting tries every split point, so checking costs O(n²) in the word's // length: a 100,000-character "word" took seconds per check under a compounding