Skip to main content

justerm_core/term/
search.rs

1//! The search query surface: finding matches across the whole buffer, and holding
2//! the highlight set the consumer hands back so it rides the frame.
3//!
4//! The engine-finds / consumer-navigates split this implements is stated once, in
5//! [`crate::search`] — do not restate it here, and read it there.
6//!
7//! What is worth saying at *this* site is why one private method lives in a query
8//! module but is called eight times from the write path. `invalidate_search_highlights`
9//! drops the held set rather than re-anchoring it, because a held match is
10//! **query-derived**: the engine keeps matches, not the query, and the set itself may
11//! have changed under the mutation. The selection is the contrast, and it is not a
12//! clean one — it is re-anchored where it can be, and *cleared* where it cannot (both
13//! alt swaps set `selection = None` on the line above the call, since neither buffer's
14//! coordinates mean anything in the other). So the rule is about what is
15//! **reproducible by the consumer**, not about re-anchoring always being available.
16//!
17//! Visibility: the seven entry points are public API and stay `pub fn` — an inherent
18//! impl's methods are reached through the type, not the module path, so a private child
19//! module does not hide them. `invalidate_search_highlights` takes `pub(super)` because
20//! its callers are the write path in `term.rs`; `word_bounded` stays private because its
21//! only caller came with it.
22
23use unicode_width::UnicodeWidthChar;
24
25use crate::search::{Match, SearchOptions};
26use crate::selection::SelectionSpan;
27
28use super::Term;
29
30impl Term {
31    /// Literal search over the whole buffer (`[scrollback ++ screen]`), returning
32    /// every non-overlapping match top-to-bottom in absolute coordinates. Matches
33    /// cross soft-wrapped rows (one logical line) and skip wide-char spacers.
34    /// Smart-case: a query with no uppercase matches case-insensitively.
35    pub fn search(&self, query: &str) -> Vec<Match> {
36        self.search_with(query, SearchOptions::default())
37    }
38
39    /// Search with explicit [`SearchOptions`] — regex, whole-word, and a case-sensitivity override
40    /// on top of the literal + smart-case [`search`](Self::search) (#314). Same coordinates,
41    /// soft-wrap join, spacer skip, and grapheme-mark inclusion (#304) as `search`.
42    pub fn search_with(&self, query: &str, opts: SearchOptions) -> Vec<Match> {
43        let q: Vec<char> = query.chars().collect();
44        if q.is_empty() {
45            return Vec::new();
46        }
47        // Smart-case unless overridden: case-insensitive iff the query has no uppercase.
48        let ci = opts
49            .case_sensitive
50            .map_or_else(|| !q.iter().any(|c| c.is_uppercase()), |cs| !cs);
51        // Fold to a single representative char so the haystack stays 1:1 with its
52        // positions (rare multi-char case expansions take their first char).
53        let fold = |c: char| {
54            if ci {
55                c.to_lowercase().next().unwrap_or(c)
56            } else {
57                c
58            }
59        };
60        let needle: Vec<char> = q.iter().map(|&c| fold(c)).collect();
61        // Regex mode: build the pattern once (case-insensitivity from the same smart-case/override
62        // decision). An invalid pattern yields no matches rather than erroring (#314).
63        let re = if opts.regex {
64            match regex::RegexBuilder::new(query).case_insensitive(ci).build() {
65                Ok(re) => Some(re),
66                Err(_) => return Vec::new(),
67            }
68        } else {
69            None
70        };
71        let total = self.scrollback.len() + self.grid.rows();
72
73        // Floored: primary matches are unreachable on alt, and a primary WRAPLINE row
74        // would otherwise soft-wrap-join into the alt grid and corrupt the haystack at
75        // the boundary. (#144)
76        let floor = self.abs_floor();
77        let mut matches = Vec::new();
78        let mut r = floor;
79        while r < total {
80            // Build the logical line at `r`: join soft-wrapped rows, recording
81            // each char's source position and skipping wide-char spacers.
82            let mut hay: Vec<char> = Vec::new();
83            let mut pos: Vec<(usize, usize)> = Vec::new();
84            let mut line = r;
85            loop {
86                let cells = self.abs_line(line);
87                for (col, cell) in cells.iter().enumerate() {
88                    if cell.is_spacer() {
89                        continue;
90                    }
91                    // Build the haystack UNFOLDED (regex needs the original text; its own
92                    // case-insensitive flag handles case). The literal path folds at compare time.
93                    hay.push(cell.c());
94                    pos.push((line, col));
95                    // Include the cell's grapheme side-table marks — combining marks, and under
96                    // mode 2027 the joined emoji scalars (2nd RI, ZWJ-joined emoji, skin tone) —
97                    // so a clustered scalar is findable, not just the base (#304). Each maps to the
98                    // same cell column, mirroring `append_cell`'s base+marks extraction.
99                    if let Some(marks) = self.combining_at(line, col) {
100                        for &m in marks {
101                            hay.push(m);
102                            pos.push((line, col));
103                        }
104                    }
105                }
106                let soft = self.abs_row(line).is_wrapped();
107                if soft && line + 1 < total {
108                    line += 1;
109                } else {
110                    break;
111                }
112            }
113            // Trim trailing blank padding (only a logical line's tail can be blank), so a regex `$`
114            // anchor or a greedy `.*` doesn't run into the grid's blank cells — mirrors
115            // `viewport_logical_lines`'s trim (#314 Lens 1). Keeps hay/pos in lockstep.
116            while hay.last().is_some_and(|c| c.is_whitespace()) {
117                hay.pop();
118                pos.pop();
119            }
120
121            // A match at char-index range [cs, ce) → a Match, whole-word-filtered and deduped.
122            // (Marks map many hay entries to one column (#304), so a repeated in-cluster scalar can
123            // yield consecutive identical Matches — collapse them.)
124            let push_range = |cs: usize, ce: usize, matches: &mut Vec<Match>| {
125                if opts.whole_word && !word_bounded(&hay, cs, ce - cs) {
126                    return;
127                }
128                let m = Match {
129                    start_line: pos[cs].0,
130                    start_col: pos[cs].1,
131                    end_line: pos[ce - 1].0,
132                    end_col: pos[ce - 1].1,
133                };
134                if matches.last() != Some(&m) {
135                    matches.push(m);
136                }
137            };
138
139            if let Some(re) = &re {
140                // Regex over the (unfolded) logical line; map each match's byte range to char indices.
141                let hay_str: String = hay.iter().collect();
142                for mat in re.find_iter(&hay_str) {
143                    if mat.start() == mat.end() {
144                        continue; // skip empty matches (e.g. `a*` between chars)
145                    }
146                    let cs = hay_str[..mat.start()].chars().count();
147                    let ce = hay_str[..mat.end()].chars().count();
148                    push_range(cs, ce, &mut matches);
149                }
150            } else {
151                // Slide the literal needle non-overlapping, folding each hay char at compare time.
152                let mut i = 0;
153                while needle.len() <= hay.len() && i + needle.len() <= hay.len() {
154                    let hit = hay[i..i + needle.len()]
155                        .iter()
156                        .enumerate()
157                        .all(|(k, &c)| fold(c) == needle[k]);
158                    if hit {
159                        let before = matches.len();
160                        push_range(i, i + needle.len(), &mut matches);
161                        // Advance past a real (accepted) match; a whole-word-rejected run advances by
162                        // one so a later, word-bounded position at an overlapping offset is still tried.
163                        i += if matches.len() > before {
164                            needle.len()
165                        } else {
166                            1
167                        };
168                    } else {
169                        i += 1;
170                    }
171                }
172            }
173            r = line + 1;
174        }
175        matches
176    }
177
178    /// Scroll the viewport so a match's start line is visible (placed at the top
179    /// when it sits in history; the live view when it is already on screen).
180    pub fn search_scroll_to(&mut self, m: &Match) {
181        let target = self.scrollback.len().saturating_sub(m.start_line);
182        self.set_display_offset(target);
183    }
184
185    /// Project a match onto the current viewport as inclusive-column spans, one
186    /// per visible row (off-screen parts dropped) — for the renderer to
187    /// highlight, like `selection_range`.
188    pub fn match_spans(&self, m: &Match) -> Vec<SelectionSpan> {
189        let rows = self.grid.rows();
190        let top = self.scrollback.len() - self.display_offset;
191        let mut spans = Vec::new();
192        for line in m.start_line..=m.end_line {
193            if line < top {
194                continue;
195            }
196            let row = line - top;
197            if row >= rows {
198                break;
199            }
200            let last = self.abs_line(line).len().saturating_sub(1);
201            let left = if line == m.start_line { m.start_col } else { 0 };
202            let right = if line == m.end_line {
203                m.end_col.min(last)
204            } else {
205                last
206            };
207            if right >= left {
208                spans.push(SelectionSpan { row, left, right });
209            }
210        }
211        spans
212    }
213
214    /// Set the search highlights to paint (#108). The consumer owns the
215    /// `Vec<Match>` (it drives next/prev); handing it back here lets `frame()`
216    /// project the highlights onto the viewport. An empty vec clears them.
217    pub fn set_search_highlights(&mut self, matches: Vec<Match>) {
218        self.search_highlights = matches;
219        // A new set voids the designation: a stale index could be accidentally
220        // in range and light wrong content (#428). The consumer re-designates.
221        self.active_search_highlight = None;
222    }
223
224    /// Designate which member of the held highlight set is the *active* match
225    /// (#428) — the one the consumer's next/prev navigation currently points at.
226    /// `frame()` projects it into `overlay.active_match` (it also stays in
227    /// `overlay.matches`; the renderer's ranking resolves the overlap, #424).
228    /// `None` or an out-of-range index projects nothing; the designation resets
229    /// whenever a new set is passed to [`set_search_highlights`](Self::set_search_highlights).
230    /// The index resolves to its span at call time (#436) — both designation
231    /// APIs converge on one stored representation.
232    pub fn set_active_search_highlight(&mut self, index: Option<usize>) {
233        self.active_search_highlight = index.and_then(|i| self.search_highlights.get(i)).copied();
234    }
235
236    /// Designate the *active* match by its absolute span (#436), independent of
237    /// the held highlight set — the past-cap path: a backend that caps its
238    /// hand-over (the documented 1000, xterm's `highlightLimit`) can still give
239    /// the current match its active emphasis, exactly as xterm creates the
240    /// active decoration from the found result outside the capped list. The
241    /// span projects through the same viewport math as any match (wrap-aware);
242    /// it need not be a member of the held set, so past the cap the match
243    /// paints the ACTIVE colour only (no plain highlight underneath). `None`
244    /// clears. Same lifecycle as the index form: reset on every
245    /// [`set_search_highlights`](Self::set_search_highlights) hand-over and on
246    /// any coordinate-shifting invalidation.
247    pub fn set_active_search_match(&mut self, m: Option<Match>) {
248        self.active_search_highlight = m;
249    }
250
251    /// Invalidate the held search highlights (#108). Called wherever a buffer
252    /// mutation shifts the *line* coordinates the matches were found at — cap
253    /// eviction, in-screen region/RI/SU/SD/IL/DL scroll, the accrual
254    /// sub-region scroll (#449 — which also re-anchors selection/markers below
255    /// the margin, `selection_shift_below_margin`), reflow, both alt swaps.
256    /// In-line *column* shifts (ICH/DCH, insert-mode print) and
257    /// in-place erases (ED/EL/ECH, overwrite) deliberately do NOT funnel — the
258    /// set stales in place there exactly like the selection sibling and
259    /// xterm's decorations, healed by the consumer's debounced re-search on
260    /// output (which those mutations are). Search matches are query-derived
261    /// (the engine holds matches, not the query, and the *set* itself may have
262    /// changed), so unlike the user-authored selection they are dropped rather
263    /// than re-anchored. Clearing avoids painting wrong content for the frame
264    /// between the mutation and the consumer's refresh.
265    pub(super) fn invalidate_search_highlights(&mut self) {
266        self.search_highlights.clear();
267        // #436: the active designation is a stored SPAN, no longer structurally
268        // tied to the set — clear it in the same funnel or it would keep
269        // painting coordinates that now hold arbitrary other text.
270        self.active_search_highlight = None;
271    }
272}
273
274/// Whether the run `hay[i..i+len]` is bounded by non-word characters on both sides — the `\bword\b`
275/// sense for whole-word search (#314). A word char is alphanumeric or `_` (the regex `\w` set),
276/// deliberately distinct from `is_word_boundary`'s wider semantic-selection set.
277fn word_bounded(hay: &[char], i: usize, len: usize) -> bool {
278    // A word char is alphanumeric, `_`, OR a grapheme-extending mark (width 0: combining marks,
279    // ZWJ, variation selectors) — so a mark attached to a base is never read as a word boundary,
280    // matching the regex `\b` sense (`\w` includes `\p{M}`) and staying consistent across the
281    // literal and regex paths on decomposed graphemes (#314 Lens 1).
282    let is_word = |c: char| c.is_alphanumeric() || c == '_' || c.width() == Some(0);
283    let left = i == 0 || !is_word(hay[i - 1]);
284    let right = i + len == hay.len() || !is_word(hay[i + len]);
285    left && right
286}