From 0233d2906424f024a89041f1f95c28c89eca20d9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 19:59:51 -0500 Subject: [PATCH] Spelling: bound a word's language and index results by offset (#1767) * engine: bound a word's language and index spelling results by offset * engine: pass the test run's text by value --- crates/engine/src/spell.rs | 50 ++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs index 2dda5c6f4..0eeaa6757 100644 --- a/crates/engine/src/spell.rs +++ b/crates/engine/src/spell.rs @@ -700,15 +700,29 @@ pub struct Word { pub repeated: Option, } +/// Longest language tag a word is checked in. BCP 47 tags in documents are a few bytes; a longer +/// value from a file is no language we have a dictionary for, and copying it into every word of +/// a paragraph would multiply it. +const MAX_LANG_LEN: usize = 64; + /// The words of `p`, each with its run's language (`lang` on the run, else `default`). Words /// with digits are skipped; apostrophes inside a word belong to it; hyphenated words are checked -/// part by part. +/// part by part. A language over [`MAX_LANG_LEN`] counts as none. pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec { let mut chars: Vec<(char, usize)> = vec![]; for (ri, r) in p.runs.iter().enumerate() { chars.extend(r.content().chars().map(|c| (c, ri))); } - let lang_of = |ri: usize| p.runs.get(ri).and_then(|r| r.props.lang.clone()).filter(|l| !l.is_empty()).unwrap_or_else(|| default.to_string()); + let default = if default.len() > MAX_LANG_LEN { "" } else { default }; + let lang_of = |ri: usize| { + p.runs + .get(ri) + .and_then(|r| r.props.lang.as_deref()) + .filter(|l| !l.is_empty()) + .map(|l| if l.len() > MAX_LANG_LEN { "" } else { l }) + .unwrap_or(default) + .to_string() + }; // Combining marks (decomposed accents) belong to the letter before them. let inside = |c: char| c.is_alphanumeric() || deckcraft_model::words::is_combining_mark(c) || c == '\'' || c == '’' || c == '-'; let mut out = vec![]; @@ -791,15 +805,18 @@ pub fn misspelt(p: &deckcraft_model::Paragraph, default: &str, ignore: &[String] .chain(repeated(p, default)) .collect::>() .into_iter() - .fold(vec![], |mut v: Vec, w| { + .fold((vec![], HashMap::new()), |(mut v, mut at): (Vec, HashMap), w| { // A repeated misspelt word is reported once, as repeated. - if let Some(i) = v.iter().position(|x| x.start == w.start) { - v[i] = w; - } else { - v.push(w); + match at.get(&w.start).and_then(|&i| v.get_mut(i)) { + Some(x) => *x = w, + None => { + at.insert(w.start, v.len()); + v.push(w); + } } - v + (v, at) }) + .0 } /// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself). @@ -953,6 +970,23 @@ SFX D Y 1\nSFX D 0 ed/S .\n"; assert_eq!(w, vec!["check", "done"]); } + #[test] + fn a_long_paragraph_with_a_huge_language_is_checked_in_linear_work() { + use deckcraft_model::{Paragraph, Run, RunProps}; + // 100,000 copies of one word under a 1 MB language value: every word after the first is + // a repeat, and no word carries a copy of the value. + let lang = "x".repeat(1 << 20); + let p = + Paragraph { runs: vec![Run::with("the ".repeat(100_000), RunProps { lang: Some(lang), ..Default::default() })], ..Default::default() }; + let words = paragraph_words(&p, "en-US"); + assert_eq!(words.len(), 100_000); + assert!(words.iter().all(|w| w.lang.is_empty())); + let bad = misspelt(&p, "en-US", &[]); + assert_eq!(bad.len(), 99_999); + assert!(bad.iter().all(|w| w.repeated.is_some())); + assert!(paragraph_words(&Paragraph::new("word"), &"y".repeat(100)).iter().all(|w| w.lang.is_empty())); + } + #[test] fn long_and_numeric_flags_and_latin1() { let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");