From 1f2afea452ccd5e230a2a0a2a01e00392443dc96 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 22:36:31 -0500 Subject: [PATCH] Proofing: look up unchecked runs by binary search, not a scan per issue paragraph_issues dropped issues touching a run left unchecked by testing every run of the paragraph against every issue. A paragraph can have many runs and many issues within the proofing size limit, so the filter was quadratic in the paragraph's size. The unchecked ranges are now merged and sorted once, each issue is looked up by binary search, and paragraphs without unchecked runs skip the filter. --- crates/layout/src/para/proof.rs | 56 +++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/para/proof.rs b/crates/layout/src/para/proof.rs index b9ffa911c8..0b97941930 100644 --- a/crates/layout/src/para/proof.rs +++ b/crates/layout/src/para/proof.rs @@ -46,8 +46,37 @@ pub fn paragraph_issues( } let text = proof_text(p); let spans: Vec<(std::ops::Range, wordcraft_proof::ProofLang)> = runs.iter().map(|r| (r.range.clone(), r.lang)).collect(); - let skip = |a: usize, b: usize| runs.iter().any(|r| r.skip && r.range.start < b && a < r.range.end); - wordcraft_proof::check_text_with(&text, &spans, spelling, grammar, list_item).into_iter().filter(|i| !skip(i.start, i.end)).collect() + let skipped = skipped_ranges(runs); + let issues = wordcraft_proof::check_text_with(&text, &spans, spelling, grammar, list_item); + if skipped.is_empty() { + return issues; + } + issues.into_iter().filter(|i| !overlaps(&skipped, i.start, i.end)).collect() +} + +/// The byte ranges of the runs left unchecked, in order, those that overlap merged: their +/// starts and ends both rise, so [`overlaps`] finds an issue's by binary search instead of +/// looking at every run for every issue. +fn skipped_ranges(runs: &[ProofRun]) -> Vec<(usize, usize)> { + let mut ranges: Vec<(usize, usize)> = runs.iter().filter(|r| r.skip).map(|r| (r.range.start, r.range.end)).collect(); + ranges.sort_unstable(); + let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len()); + for (s, e) in ranges { + crate::work::steps(1); + match merged.last_mut() { + Some(last) if s < last.1 => last.1 = last.1.max(e), + _ => merged.push((s, e)), + } + } + merged +} + +/// Whether bytes `a..b` overlap one of `skipped` ([`skipped_ranges`]): the first range ending +/// after `a` is the only one that can, as every later one starts no earlier. +fn overlaps(skipped: &[(usize, usize)], a: usize, b: usize) -> bool { + crate::work::steps(1); + let k = skipped.partition_point(|&(_, e)| e <= a); + skipped.get(k).is_some_and(|&(s, _)| s < b) } /// Whether the paragraph is a list item: its own numbering, or its style's, names a list (not 0). @@ -101,3 +130,26 @@ pub fn proof_text(p: &wordcraft_doc::Paragraph) -> String { } out } + +#[cfg(test)] +mod tests { + use super::*; + + fn run(range: std::ops::Range, skip: bool) -> ProofRun { + ProofRun { range, lang: Default::default(), skip } + } + + /// The lookup finds what a look at every run finds, overlapping and empty runs included. + #[test] + fn skipped_lookup_matches_a_scan_of_every_run() { + let runs = [run(0..4, false), run(4..9, true), run(5..6, true), run(9..9, true), run(9..12, false), run(12..20, true), run(14..30, true)]; + let skipped = skipped_ranges(&runs); + for a in 0..32 { + for b in a..32 { + let scan = runs.iter().any(|r| r.skip && r.range.start < b && a < r.range.end); + assert_eq!(overlaps(&skipped, a, b), scan, "{a}..{b}"); + } + } + assert!(skipped_ranges(&[run(0..5, false)]).is_empty()); + } +}