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 only other whole-document string a caller can reach 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.
51///
52/// The chars are collected **once** for the whole match list, not re-walked per match, which
53/// would be quadratic on a document where the query occurs often — exactly the document
54/// where it matters.
55pub fn matched_texts(full_text: &str, matches: &[(usize, usize)]) -> Vec<String> {
56 if matches.is_empty() {
57 return Vec::new();
58 }
59 let chars: Vec<char> = full_text.chars().collect();
60 matches
61 .iter()
62 .map(|&(position, length)| {
63 let start = position.min(chars.len());
64 let end = (position + length).min(chars.len());
65 chars[start..end].iter().collect()
66 })
67 .collect()
68}
69
70/// Every DTO that describes a *search* carries the same four fields, and they must reach the
71/// matcher together. Written as conversions rather than as four positional arguments,
72/// because a `find_all_matches(text, query, true, false, false, "")` call site is a bug
73/// waiting for someone to transpose two bools — and one of those bools decides whether a
74/// rename touches half a manuscript.
75macro_rules! match_options_from {
76 ($dto:ty) => {
77 impl From<&$dto> for MatchOptions {
78 fn from(dto: &$dto) -> Self {
79 MatchOptions {
80 case_sensitive: dto.case_sensitive,
81 diacritic_sensitive: dto.diacritic_sensitive,
82 whole_word: dto.whole_word,
83 locale: FoldLocale::from_tag(&dto.language),
84 }
85 }
86 }
87 };
88}
89
90match_options_from!(crate::FindTextDto);
91match_options_from!(crate::FindAllDto);
92match_options_from!(crate::ReplaceTextDto);
93
94thread_local! {
95 /// Compiled regexes, keyed by `(pattern, case_sensitive)`.
96 ///
97 /// The regex was recompiled on **every call** — including once per keystroke of a
98 /// search-as-you-type box, where compilation dwarfs the scan it precedes.
99 static REGEX_CACHE: RefCell<HashMap<(String, bool), Regex>> = RefCell::new(HashMap::new());
100}
101
102/// How many compiled regexes to keep. A search box produces one entry per prefix of what
103/// is typed, so this is bounded rather than unbounded; the cache is cleared wholesale on
104/// overflow because the working set is "the pattern being typed right now", not an LRU.
105const REGEX_CACHE_CAP: usize = 32;
106
107fn compiled_regex(pattern: &str, case_sensitive: bool) -> Result<Regex> {
108 let key = (pattern.to_string(), case_sensitive);
109 REGEX_CACHE.with(|cache| {
110 if let Some(re) = cache.borrow().get(&key) {
111 return Ok(re.clone());
112 }
113 let re = RegexBuilder::new(pattern)
114 .case_insensitive(!case_sensitive)
115 .size_limit(1 << 20) // 1 MB compiled size limit
116 .dfa_size_limit(1 << 20)
117 .build()
118 .map_err(|e| anyhow!("Invalid regex pattern: {}", e))?;
119 let mut cache = cache.borrow_mut();
120 if cache.len() >= REGEX_CACHE_CAP {
121 cache.clear();
122 }
123 cache.insert(key, re.clone());
124 Ok(re)
125 })
126}
127
128/// Find all occurrences of the query in the text, respecting search options.
129/// All positions are in char indices (not byte offsets).
130/// Returns a vec of `(char_position, char_length)` for each match.
131///
132/// The literal path delegates to [`crate::matching`] — the one definition of "a match",
133/// shared with the public API so a host app's project-wide search and this in-document find
134/// can never disagree about whole-word rules or folding.
135///
136/// ## The regex path
137///
138/// The pattern runs against the **folded** haystack, with the same index map back to the
139/// source that the literal path uses — so `diacritic_sensitive` is honoured here too rather
140/// than being an option that silently means nothing on half the calls that take it.
141///
142/// *Case* is the exception: it stays with the regex engine (`(?i)`), searching the
143/// unfolded-for-case text. Fold the case away and a pattern like `[A-Z]` would match
144/// nothing, because there would be no uppercase left to match — the author of a regex is
145/// addressing the text as written.
146pub fn find_all_matches(
147 full_text: &str,
148 query: &str,
149 options: &MatchOptions,
150 use_regex: bool,
151) -> Result<Vec<(usize, usize)>> {
152 if query.is_empty() {
153 return Ok(Vec::new());
154 }
155
156 if !use_regex {
157 return Ok(matching::find_all(full_text, query, options)
158 .into_iter()
159 .map(|m| (m.char_start, m.char_len))
160 .collect());
161 }
162
163 let re = compiled_regex(query, options.case_sensitive)?;
164 let folded = matching::Folded::new_for(
165 full_text,
166 &MatchOptions {
167 // The regex engine owns case; this fold owns diacritics. Each mechanism does
168 // exactly one job, and neither undoes the other.
169 case_sensitive: true,
170 ..*options
171 },
172 );
173 let boundaries = options
174 .whole_word
175 .then(|| matching::word_boundaries(full_text));
176
177 let mut results = Vec::new();
178 for mat in re.find_iter(folded.text()) {
179 // A regex can match **nothing** — `a*`, `x?`, `\b` all match the empty string at every
180 // position. The literal path cannot produce one (an empty needle returns early), so
181 // nothing downstream expects one: `replace_text` would take a zero-length range as an
182 // *insertion* and splice the replacement in at every character of the document, having
183 // matched nothing at all. Refuse it here, where the concept of an empty match exists.
184 if mat.start() == mat.end() {
185 continue;
186 }
187 // A regex can match at a position that is not a char start only if the pattern
188 // matched inside a multi-byte char, which `regex` does not do — but the map is a
189 // lookup, not an assumption, so a miss is skipped rather than panicking.
190 let (Some(folded_start), Some(folded_end)) = (
191 folded.char_of_byte(mat.start()),
192 folded.char_of_byte(mat.end()),
193 ) else {
194 continue;
195 };
196 // Same guard as the literal path: a span that begins or ends inside one source
197 // char's expansion names half a letter, and there is no source range to report.
198 let Some(m) = folded.to_source_match(folded_start, folded_end) else {
199 continue;
200 };
201
202 if let Some(boundaries) = &boundaries
203 && !(matching::is_boundary(boundaries, m.char_start)
204 && matching::is_boundary(boundaries, m.char_start + m.char_len))
205 {
206 continue;
207 }
208 results.push((m.char_start, m.char_len));
209 }
210
211 Ok(results)
212}