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()); + } +}