From 6af917e869409c76be36bbb9b2b8e9bea1bfeaa1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:27:51 +0000 Subject: [PATCH] Spelling: stop an affix header's count at the end of the file, share aliased dictionaries --- crates/engine/src/spell.rs | 42 +++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs index 2dda5c6f4..a04710eb4 100644 --- a/crates/engine/src/spell.rs +++ b/crates/engine/src/spell.rs @@ -171,7 +171,9 @@ impl Dictionary { ["COMPOUNDWORDMAX", n, ..] => d.compound_max = n.parse().unwrap_or(0), ["REP", n] => { for _ in 0..n.parse::().unwrap_or(0) { - let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect(); + // The count is the file's claim: stop when the records run out. + let Some(line) = lines.next() else { break }; + let r: Vec<&str> = line.split_whitespace().collect(); if let [_, a, b, ..] = r.as_slice() { d.rep.push((a.replace('_', " "), b.replace('_', " "))); } @@ -182,7 +184,8 @@ impl Dictionary { let Some(flag) = one_flag(Some(flag), mode) else { continue }; let cross = cross.eq_ignore_ascii_case("y"); for _ in 0..n.parse::().unwrap_or(0) { - let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect(); + let Some(line) = lines.next() else { break }; + let r: Vec<&str> = line.split_whitespace().collect(); let [_, _, strip, add, rest @ ..] = r.as_slice() else { continue }; let (add, cont) = match add.split_once('/') { Some((a, c)) => (a, parse_flags(c, mode)), @@ -576,6 +579,15 @@ fn cache() -> &'static Cache { CACHE.get_or_init(Default::default) } +/// Language strings remembered by [`dictionary`]; a string past it is resolved again on each call. +const MAX_CACHED_LANGS: usize = 256; + +/// Parsed dictionaries by their affix file, so aliases of one language share it. +fn by_files() -> &'static Mutex>> { + static M: OnceLock>>> = OnceLock::new(); + M.get_or_init(Default::default) +} + /// The dictionary for `lang` (a BCP-47 tag such as `en-US`), loaded once per process. pub fn dictionary(lang: &str) -> Option> { let cache = cache(); @@ -586,10 +598,22 @@ pub fn dictionary(lang: &str) -> Option> { return d.clone(); } let loaded = find_in(&search_dirs(), lang).and_then(|(a, d)| { - let (a, d) = (std::fs::read(a).ok()?, std::fs::read(d).ok()?); - Some(Arc::new(Dictionary::parse(&a, &d))) + // Tags that resolve to the same files share one parsed dictionary. + if let Ok(m) = by_files().lock() + && let Some(dict) = m.get(&a) + { + return Some(dict.clone()); + } + let (bytes_a, bytes_d) = (std::fs::read(&a).ok()?, std::fs::read(d).ok()?); + let dict = Arc::new(Dictionary::parse(&bytes_a, &bytes_d)); + if let Ok(mut m) = by_files().lock() { + m.insert(a, dict.clone()); + } + Some(dict) }); - if let Ok(mut c) = cache.lock() { + if let Ok(mut c) = cache.lock() + && c.len() < MAX_CACHED_LANGS + { c.insert(key, loaded.clone()); } loaded @@ -880,6 +904,14 @@ SFX D Y 1\nSFX D 0 ed/S .\n"; assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string())); } + /// An affix header can claim billions of records; parsing stops where the file does. + #[test] + fn a_header_count_past_the_records_ends_with_them() { + let aff = b"SFX S Y 4000000000\nSFX S 0 s .\nREP 4000000000\nREP a b\n"; + let d = Dictionary::parse(aff, b"1\ncat/S\n".as_slice()); + assert!(d.check("cats")); + } + /// #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]