Skip to main content

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}