diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index 1d7e0e6a9..09c6459e0 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -23,6 +23,13 @@ use designcraft_doc::story; pub const MAX_PATTERN: usize = 4096; /// Backtracking steps one search may take before it gives up. pub const BACKTRACK_LIMIT: usize = 200_000; +/// Matches one GREP style may contribute to one paragraph. +/// +/// [`BACKTRACK_LIMIT`] bounds a single search, not a scan: `find_all` pays it again for every +/// match, so a pattern tuned just under the limit cost `BACKTRACK_LIMIT × matches` per paragraph +/// on every relayout (#1598). A paragraph cannot usefully carry more styled runs than this, and +/// a scan that reaches it stops with what it has. +pub const MAX_MATCHES_PER_PARAGRAPH: usize = 4096; /// Compiled program size cap (bytes) for the underlying regex engine. const SIZE_LIMIT: usize = 4 << 20; diff --git a/crates/compose/src/overlay.rs b/crates/compose/src/overlay.rs index d65460949..5c95b0ec8 100644 --- a/crates/compose/src/overlay.rs +++ b/crates/compose/src/overlay.rs @@ -9,9 +9,42 @@ use designcraft_doc::{GrepStyle, NestedStyle, NestedUntil}; use crate::grep::{Grep, GrepOptions}; +/// Patterns kept compiled at once. A document's GREP styles are few; the cache grew without +/// bound across every pattern a session ever composed (#1598). +const MAX_CACHED_PATTERNS: usize = 64; + thread_local! { - /// Compiled GREP styles by pattern; None for one that doesn't compile or gave up on a search. - static PATTERNS: RefCell>> = RefCell::new(HashMap::new()); + /// Compiled GREP styles by pattern, with the tick they were last used at; `None` for one that + /// doesn't compile or gave up on a search. Bounded to [`MAX_CACHED_PATTERNS`], least recently + /// used evicted first. + static PATTERNS: RefCell, u64)>> = RefCell::new(HashMap::new()); + /// Counts cache uses, so the least recently used entry can be found. + static TICK: RefCell = const { RefCell::new(0) }; +} + +/// The compiled pattern for `pattern`, compiling it on first use and evicting the least recently +/// used entry once the cache is full. `f` is given `None` when the pattern does not compile. +fn with_pattern(pattern: &str, f: impl FnOnce(&mut Option) -> R) -> R { + let now = TICK.with(|t| { + let mut t = t.borrow_mut(); + *t += 1; + *t + }); + PATTERNS.with(|p| { + let mut p = p.borrow_mut(); + if !p.contains_key(pattern) + && p.len() >= MAX_CACHED_PATTERNS + && let Some(oldest) = p.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone()) + { + p.remove(&oldest); + } + let slot = p.entry(pattern.to_string()).or_insert_with(|| { + let re = Grep::new(pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {pattern:?} skipped: {e}")).ok(); + (re, now) + }); + slot.1 = now; + f(&mut slot.0) + }) } fn is_event(u: &NestedUntil, c: char, prev: Option, next: Option) -> bool { @@ -71,14 +104,15 @@ pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], if g.style.is_empty() || g.pattern.is_empty() { continue; } - PATTERNS.with(|p| { - let mut p = p.borrow_mut(); - let slot = p.entry(g.pattern.clone()).or_insert_with(|| { - Grep::new(&g.pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {:?} skipped: {e}", g.pattern)).ok() - }); + with_pattern(&g.pattern, |slot| { let Some(re) = slot else { return }; - match re.find_all(text.get(range.start..end).unwrap_or(""), usize::MAX) { - Ok(ms) => out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone()))), + match re.find_all(text.get(range.start..end).unwrap_or(""), crate::grep::MAX_MATCHES_PER_PARAGRAPH) { + Ok(ms) => { + if ms.len() == crate::grep::MAX_MATCHES_PER_PARAGRAPH { + log::warn!("GREP style {:?} matched more than {} times in one paragraph; the rest are not styled", g.pattern, ms.len()); + } + out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone()))); + } Err(e) => { log::warn!("GREP style {:?} disabled: {e}", g.pattern); *slot = None; @@ -93,6 +127,31 @@ pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], mod tests { use super::*; + /// A GREP style's scan is bounded two ways (#1598): the matches one paragraph can take, and + /// the patterns kept compiled at once. + #[test] + fn a_grep_scan_and_the_pattern_cache_are_bounded() { + // More matches in one paragraph than the cap: the scan stops with what it has instead of + // paying the backtrack limit again for each one. + let text = "a ".repeat(crate::grep::MAX_MATCHES_PER_PARAGRAPH + 500); + let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "A".into(), pattern: "a".into() }], 0); + assert_eq!(o.len(), crate::grep::MAX_MATCHES_PER_PARAGRAPH, "the scan stops at the cap"); + + // The cache holds at most MAX_CACHED_PATTERNS, and the one just used is not the one + // dropped: a document's own styles stay compiled however many patterns a session saw. + PATTERNS.with(|p| p.borrow_mut().clear()); + for i in 0..MAX_CACHED_PATTERNS + 20 { + let pattern = format!("x{i}y"); + overlays("x1y", 0..3, &[], &[GrepStyle { style: "S".into(), pattern }], 0); + } + let (len, has_last) = PATTERNS.with(|p| { + let p = p.borrow(); + (p.len(), p.contains_key(&format!("x{}y", MAX_CACHED_PATTERNS + 19))) + }); + assert_eq!(len, MAX_CACHED_PATTERNS, "the cache is bounded"); + assert!(has_last, "the most recently used pattern is still cached"); + } + #[test] fn nested_and_grep_ranges() { let text = "Q: what is 42? A drop of 7 words.\n".to_string();