text_document_search/use_cases/search_helpers.rs
1use std::cell::RefCell;
2use std::collections::HashMap;
3
4use anyhow::{Result, anyhow};
5use common::database::Store;
6use common::database::rope_helpers::block_content_via_store;
7use common::entities::Block;
8use regex::{Regex, RegexBuilder};
9
10use crate::matching::{self, FoldLocale, MatchOptions};
11
12/// Build the full document text from the given blocks by reading content
13/// from the global rope via `block_offsets`.
14///
15/// This is the *fallback* path used only when the rope's char space does
16/// not match document flow (a sub-frame inserted with a parent — its
17/// blocks aren't mirrored to the rope). The primary search path reads the
18/// whole rope directly via `rope_full_text_if_flow_matches`, which already
19/// covers table-cell content (cells are mirrored inline into the rope in
20/// document order). Blocks not registered in the offset index contribute
21/// the empty string — see `block_content_via_store`.
22///
23/// The returned string has blocks joined by single `\n` separators —
24/// matching the position semantics that `document_position` is computed
25/// against.
26///
27/// Blocks must be sorted by `document_position` (caller's
28/// responsibility).
29pub fn build_full_text_via_store(blocks: &[Block], store: &Store) -> String {
30 let mut out = String::new();
31 for (i, block) in blocks.iter().enumerate() {
32 if i > 0 {
33 out.push('\n');
34 }
35 out.push_str(&block_content_via_store(block, store));
36 }
37 out
38}
39
40/// The text each match actually covers, sliced from the text the search ran on.
41///
42/// Done here, and returned to the caller, so that **nobody slices it themselves**. Two
43/// reasons, and both have already bitten this crate:
44///
45/// - With folding on, the query is not the matched text. `cafe` matches `café`; `strasse`
46/// matches `straße`. A caller that echoed the query back would show the writer a word that
47/// is not in their book.
48/// - The whole-document string a caller reaches for first is `to_plain_text`, which does
49/// not share this offset space — it carries no `U+FFFC` anchor for an embedded table, so
50/// slicing it with these offsets is wrong by two chars per preceding table. (The string
51/// that *does* share it is served by `addressable_text_uc`, for the callers that need
52/// text and offsets to travel together.)
53///
54/// The chars are collected **once** for the whole match list, not re-walked per match, which
55/// would be quadratic on a document where the query occurs often — exactly the document
56/// where it matters.
57pub fn matched_texts(full_text: &str, matches: &[(usize, usize)]) -> Vec<String> {
58 if matches.is_empty() {
59 return Vec::new();
60 }
61 let chars: Vec<char> = full_text.chars().collect();
62 matches
63 .iter()
64 .map(|&(position, length)| {
65 let start = position.min(chars.len());
66 let end = (position + length).min(chars.len());
67 chars[start..end].iter().collect()
68 })
69 .collect()
70}
71
72/// Every DTO that describes a *search* carries the same four fields, and they must reach the
73/// matcher together. Written as conversions rather than as four positional arguments,
74/// because a `find_all_matches(text, query, true, false, false, "")` call site is a bug
75/// waiting for someone to transpose two bools — and one of those bools decides whether a
76/// rename touches half a manuscript.
77macro_rules! match_options_from {
78 ($dto:ty) => {
79 impl From<&$dto> for MatchOptions {
80 fn from(dto: &$dto) -> Self {
81 MatchOptions {
82 case_sensitive: dto.case_sensitive,
83 diacritic_sensitive: dto.diacritic_sensitive,
84 whole_word: dto.whole_word,
85 locale: FoldLocale::from_tag(&dto.language),
86 }
87 }
88 }
89 };
90}
91
92match_options_from!(crate::FindTextDto);
93match_options_from!(crate::FindAllDto);
94match_options_from!(crate::ReplaceTextDto);
95
96thread_local! {
97 /// Compiled regexes, keyed by `(pattern, case_sensitive)`.
98 ///
99 /// The regex was recompiled on **every call** — including once per keystroke of a
100 /// search-as-you-type box, where compilation dwarfs the scan it precedes.
101 static REGEX_CACHE: RefCell<HashMap<(String, bool), Regex>> = RefCell::new(HashMap::new());
102}
103
104/// How many compiled regexes to keep. A search box produces one entry per prefix of what
105/// is typed, so this is bounded rather than unbounded; the cache is cleared wholesale on
106/// overflow because the working set is "the pattern being typed right now", not an LRU.
107const REGEX_CACHE_CAP: usize = 32;
108
109fn compiled_regex(pattern: &str, case_sensitive: bool) -> Result<Regex> {
110 let key = (pattern.to_string(), case_sensitive);
111 REGEX_CACHE.with(|cache| {
112 if let Some(re) = cache.borrow().get(&key) {
113 return Ok(re.clone());
114 }
115 let re = RegexBuilder::new(pattern)
116 .case_insensitive(!case_sensitive)
117 .size_limit(1 << 20) // 1 MB compiled size limit
118 .dfa_size_limit(1 << 20)
119 .build()
120 .map_err(|e| anyhow!("Invalid regex pattern: {}", e))?;
121 let mut cache = cache.borrow_mut();
122 if cache.len() >= REGEX_CACHE_CAP {
123 cache.clear();
124 }
125 cache.insert(key, re.clone());
126 Ok(re)
127 })
128}
129
130/// Find all occurrences of the query in the text, respecting search options.
131/// All positions are in char indices (not byte offsets).
132/// Returns a vec of `(char_position, char_length)` for each match.
133///
134/// The literal path delegates to [`crate::matching`] — the one definition of "a match",
135/// shared with the public API so a host app's project-wide search and this in-document find
136/// can never disagree about whole-word rules or folding.
137///
138/// ## The regex path
139///
140/// The pattern runs against the **folded** haystack, with the same index map back to the
141/// source that the literal path uses — so `diacritic_sensitive` is honoured here too rather
142/// than being an option that silently means nothing on half the calls that take it.
143///
144/// *Case* is the exception: it stays with the regex engine (`(?i)`), searching the
145/// unfolded-for-case text. Fold the case away and a pattern like `[A-Z]` would match
146/// nothing, because there would be no uppercase left to match — the author of a regex is
147/// addressing the text as written.
148pub fn find_all_matches(
149 full_text: &str,
150 query: &str,
151 options: &MatchOptions,
152 use_regex: bool,
153) -> Result<Vec<(usize, usize)>> {
154 if query.is_empty() {
155 return Ok(Vec::new());
156 }
157
158 if !use_regex {
159 return Ok(matching::find_all(full_text, query, options)
160 .into_iter()
161 .map(|m| (m.char_start, m.char_len))
162 .collect());
163 }
164
165 let re = compiled_regex(query, options.case_sensitive)?;
166 let folded = matching::Folded::new_for(
167 full_text,
168 &MatchOptions {
169 // The regex engine owns case; this fold owns diacritics. Each mechanism does
170 // exactly one job, and neither undoes the other.
171 case_sensitive: true,
172 ..*options
173 },
174 );
175 let boundaries = options
176 .whole_word
177 .then(|| matching::word_boundaries(full_text));
178
179 let mut results = Vec::new();
180 for mat in re.find_iter(folded.text()) {
181 // A regex can match **nothing** — `a*`, `x?`, `\b` all match the empty string at every
182 // position. The literal path cannot produce one (an empty needle returns early), so
183 // nothing downstream expects one: `replace_text` would take a zero-length range as an
184 // *insertion* and splice the replacement in at every character of the document, having
185 // matched nothing at all. Refuse it here, where the concept of an empty match exists.
186 if mat.start() == mat.end() {
187 continue;
188 }
189 // A regex can match at a position that is not a char start only if the pattern
190 // matched inside a multi-byte char, which `regex` does not do — but the map is a
191 // lookup, not an assumption, so a miss is skipped rather than panicking.
192 let (Some(folded_start), Some(folded_end)) = (
193 folded.char_of_byte(mat.start()),
194 folded.char_of_byte(mat.end()),
195 ) else {
196 continue;
197 };
198 // Same guard as the literal path: a span that begins or ends inside one source
199 // char's expansion names half a letter, and there is no source range to report.
200 let Some(m) = folded.to_source_match(folded_start, folded_end) else {
201 continue;
202 };
203
204 if let Some(boundaries) = &boundaries
205 && !(matching::is_boundary(boundaries, m.char_start)
206 && matching::is_boundary(boundaries, m.char_start + m.char_len))
207 {
208 continue;
209 }
210 results.push((m.char_start, m.char_len));
211 }
212
213 Ok(results)
214}