Files
wordcraft/crates/layout/src/para/proof.rs
T
Hamza NaveedandBrandon Thomas ee8622ddb3 Count the runs proofing text walks, instead of timing it (#5185) (#5190)
* Count the runs proofing text walks, instead of timing it (#5185)

`proof_text_walks_the_runs_once` was the one linear-time test here that timed
its walk, and the ratio crept over its 24x limit when the crate's tests ran in
parallel -- 28.2, 26.4, 25.6 -- from cache and memory contention rather than
from any regression.

The file's own module doc says why the others don't have this problem: "The
cost is the steps the lookups' walks count (crate::work), not a timing, so a
busy machine can't make a linear lookup look quadratic or the other way round."
It then says this one is timed because "proofing text, whose walk is the
document model's" counts no steps -- but `proof_text` does the walk itself,
advancing a runs iterator alongside the text, so it can count the runs it looks
at like every other walk here. Nothing in `wordcraft-doc` had to change.

    before   8x the runs took 28.2x the time (limit 24), under load
    after    50000 steps for n, 400000 for 8n, ratio 8.0 (limit 12)

The count is the same on any machine under any load, so the flakiness is gone
rather than made less likely: raising the limit to 32x would have left the same
failure available on a busier machine. It is also about 100x faster -- 0.03 s
against three rounds of eight repetitions -- and `work::steps` is `#[cfg(test)]`
inside, so release builds are untouched.

The test still catches what it was written for. Putting the quadratic walk back
(the run for each character found from the first run, every time) gives:

    proof_text: 1250025001 steps for n, 80000200001 for 8n, ratio 64.0
    panicked: 8x the input took 64.0x the steps: not linear

Verified under the reported condition: three runs of the whole crate suite in
parallel, 559 tests, all green.

* linear tests: proofing text is counted now, not timed

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 04:55:59 -05:00

104 lines
4.7 KiB
Rust

use super::{CharProps, Document, Paragraph, ResolvedChar};
/// One run's proofing: its bytes in the paragraph, its language (`w:lang`, resolved through the
/// styles: the document default, paragraph and character styles, direct formatting) and whether
/// it is left unchecked ("Do not check spelling or grammar", hidden text, hyperlinks).
#[derive(Clone, Debug, PartialEq)]
pub struct ProofRun {
pub range: std::ops::Range<usize>,
pub lang: wordcraft_proof::ProofLang,
pub skip: bool,
}
impl ProofRun {
pub(super) fn of(range: std::ops::Range<usize>, rc: &ResolvedChar) -> ProofRun {
ProofRun { range, lang: wordcraft_proof::ProofLang::from_tag(rc.lang.as_deref()), skip: rc.no_proof || rc.hidden || rc.link.is_some() }
}
}
/// The proofing runs of a paragraph, in order. `table` is the cell's table-style character
/// formatting ([`CellText::chr`]) for a paragraph in a table, as layout resolves it.
pub fn proof_runs(doc: &Document, p: &Paragraph, table: Option<&CharProps>) -> Vec<ProofRun> {
let style = p.props.style.as_deref();
p.run_ranges().map(|(r, c)| ProofRun::of(r, &doc.styles.resolve_char_in(style, table, c))).collect()
}
/// The language of the run holding byte `off` (English past the last run).
pub(super) fn lang_at(runs: &[ProofRun], off: usize) -> wordcraft_proof::ProofLang {
let k = runs.partition_point(|r| r.range.end <= off);
runs.get(k).filter(|r| r.range.start <= off).map(|r| r.lang).unwrap_or_default()
}
/// Spelling and grammar issues in a paragraph, in order: each word in its run's language
/// ([`wordcraft_proof::check_text_with`]: what spelling skips, and grammar only with `grammar`),
/// and none touching a run left unchecked.
///
/// `list_item` (see [`is_list_item`]) turns off the capital-first-word grammar rule.
pub fn paragraph_issues(
p: &Paragraph,
runs: &[ProofRun],
spelling: &wordcraft_proof::Options,
grammar: bool,
list_item: bool,
) -> Vec<wordcraft_proof::Issue> {
if p.text.trim().is_empty() || p.text.len() > 100_000 {
return Vec::new();
}
let text = proof_text(p);
let spans: Vec<(std::ops::Range<usize>, 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()
}
/// Whether the paragraph is a list item: its own numbering, or its style's, names a list (not 0).
pub fn is_list_item(doc: &Document, p: &Paragraph) -> bool {
p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).is_some_and(|n| n.num != 0)
}
/// Spelling and grammar issues as squiggles (start, end, is grammar), less spelling issues overlapping `typing`.
pub(super) fn proof_issues(
p: &Paragraph,
runs: &[ProofRun],
opts: &crate::ProofOptions,
typing: Option<(usize, usize)>,
list_item: bool,
) -> Vec<(usize, usize, bool)> {
paragraph_issues(p, runs, &opts.spelling, opts.grammar, list_item)
.into_iter()
.map(|i| (i.start, i.end, i.kind == wordcraft_proof::IssueKind::Grammar))
.filter(|&(a, b, grammar)| grammar || !typing.is_some_and(|(s, e)| a < e && s < b))
.collect()
}
/// Text for proofing with the same byte offsets: inline objects and tracked deletions become
/// U+0001 bytes (neither words nor spaces).
pub fn proof_text(p: &wordcraft_doc::Paragraph) -> String {
let mut out = String::with_capacity(p.text.len());
// The run holding each character ([`Paragraph::props_of_char`]), found by walking the runs
// along with the text rather than from the first run for every character.
let mut runs = p.runs.iter();
let mut run = runs.next();
let mut end = run.map_or(0, |r| r.len);
// Each run this walk looks at is a step ([`crate::work`]), so the linear-time test counts
// them instead of timing the walk: a caller that went back to the first run for every
// character would count the runs once per character and show up as a quadratic count (#5185).
crate::work::steps(usize::from(run.is_some()));
for (i, c) in p.text.char_indices() {
while run.is_some() && i >= end {
run = runs.next();
end = end.saturating_add(run.map_or(0, |r| r.len));
crate::work::steps(usize::from(run.is_some()));
}
let props = run.map_or(&p.mark, |r| &r.props);
let deleted = c == wordcraft_doc::para::OBJ || props.del.is_some();
if deleted {
for _ in 0..c.len_utf8() {
out.push('\u{1}');
}
} else {
out.push(c);
}
}
out
}