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    ///
36    /// **This is not a place to bound the soft-wrap run.** It joins runs the way
37    /// [`Term::viewport_logical_lines`] does and shares that walk's unboundedness, but not
38    /// its cost: measured at 14.1 ms against 13.9 ms (~1.0×) between a buffer that is one
39    /// giant run and the same bytes as short lines, because it scans everything either way
40    /// — the run is one big allocation instead of many small ones, same total work. A
41    /// per-run cap here buys nothing, and xterm's search walk is uncapped for the same
42    /// reason (its 2048/direction cap is in the link provider, which runs a regex). #206.
43    pub fn search(&self, query: &str) -> Vec<Match> {
44        self.search_with(query, SearchOptions::default())
45    }
46
47    /// Search with explicit [`SearchOptions`] — regex, whole-word, and a case-sensitivity override
48    /// on top of the literal + smart-case [`search`](Self::search) (#314). Same coordinates,
49    /// soft-wrap join, spacer skip, and grapheme-mark inclusion (#304) as `search`.
50    pub fn search_with(&self, query: &str, opts: SearchOptions) -> Vec<Match> {
51        let q: Vec<char> = query.chars().collect();
52        if q.is_empty() {
53            return Vec::new();
54        }
55        // Smart-case unless overridden: case-insensitive iff the query has no uppercase.
56        let ci = opts
57            .case_sensitive
58            .map_or_else(|| !q.iter().any(|c| c.is_uppercase()), |cs| !cs);
59        // Fold to a single representative char so the haystack stays 1:1 with its
60        // positions (rare multi-char case expansions take their first char).
61        let fold = |c: char| {
62            if ci {
63                c.to_lowercase().next().unwrap_or(c)
64            } else {
65                c
66            }
67        };
68        let needle: Vec<char> = q.iter().map(|&c| fold(c)).collect();
69        // Regex mode: build the pattern once (case-insensitivity from the same smart-case/override
70        // decision). An invalid pattern yields no matches rather than erroring (#314).
71        let re = if opts.regex {
72            match regex::RegexBuilder::new(query).case_insensitive(ci).build() {
73                Ok(re) => Some(re),
74                Err(_) => return Vec::new(),
75            }
76        } else {
77            None
78        };
79        let total = self.scrollback.len() + self.grid.rows();
80
81        // Floored: primary matches are unreachable on alt, and a primary WRAPLINE row
82        // would otherwise soft-wrap-join into the alt grid and corrupt the haystack at
83        // the boundary. (#144)
84        let floor = self.abs_floor();
85        let mut matches = Vec::new();
86        let mut r = floor;
87        while r < total {
88            // Build the logical line at `r`: join soft-wrapped rows, recording
89            // each char's source position and skipping wide-char spacers.
90            let mut hay: Vec<char> = Vec::new();
91            let mut pos: Vec<(usize, usize)> = Vec::new();
92            let mut line = r;
93            loop {
94                let cells = self.abs_line(line);
95                for (col, cell) in cells.iter().enumerate() {
96                    if cell.is_spacer() {
97                        continue;
98                    }
99                    // Build the haystack UNFOLDED (regex needs the original text; its own
100                    // case-insensitive flag handles case). The literal path folds at compare time.
101                    hay.push(cell.c());
102                    pos.push((line, col));
103                    // Include the cell's grapheme side-table marks — combining marks, and under
104                    // mode 2027 the joined emoji scalars (2nd RI, ZWJ-joined emoji, skin tone) —
105                    // so a clustered scalar is findable, not just the base (#304). Each maps to the
106                    // same cell column, mirroring `append_cell`'s base+marks extraction.
107                    if let Some(marks) = self.combining_at(line, col) {
108                        for &m in marks {
109                            hay.push(m);
110                            pos.push((line, col));
111                        }
112                    }
113                }
114                let soft = self.abs_row(line).is_wrapped();
115                if soft && line + 1 < total {
116                    line += 1;
117                } else {
118                    break;
119                }
120            }
121            // Trim trailing blank padding (only a logical line's tail can be blank), so a regex `$`
122            // anchor or a greedy `.*` doesn't run into the grid's blank cells — mirrors
123            // `viewport_logical_lines`'s trim (#314 Lens 1). Keeps hay/pos in lockstep.
124            // The predicate is `' '` and not `is_whitespace()` (#685): this decides where the
125            // haystack *ends*, so a property test moved `$` onto the wrong column as well as
126            // making a written U+00A0 unfindable. See
127            // `docs/map/invariant/only-u0020-can-be-padding.md`.
128            while hay.last().is_some_and(|c| *c == ' ') {
129                hay.pop();
130                pos.pop();
131            }
132
133            // A match at char-index range [cs, ce) → a Match, whole-word-filtered and deduped.
134            // (Marks map many hay entries to one column (#304), so a repeated in-cluster scalar can
135            // yield consecutive identical Matches — collapse them.)
136            let push_range = |cs: usize, ce: usize, matches: &mut Vec<Match>| {
137                if opts.whole_word && !word_bounded(&hay, cs, ce - cs) {
138                    return;
139                }
140                let m = Match {
141                    start_line: pos[cs].0,
142                    start_col: pos[cs].1,
143                    end_line: pos[ce - 1].0,
144                    end_col: pos[ce - 1].1,
145                };
146                if matches.last() != Some(&m) {
147                    matches.push(m);
148                }
149            };
150
151            if let Some(re) = &re {
152                // Regex over the (unfolded) logical line; map each match's byte range to char indices.
153                let hay_str: String = hay.iter().collect();
154                for mat in re.find_iter(&hay_str) {
155                    if mat.start() == mat.end() {
156                        continue; // skip empty matches (e.g. `a*` between chars)
157                    }
158                    let cs = hay_str[..mat.start()].chars().count();
159                    let ce = hay_str[..mat.end()].chars().count();
160                    push_range(cs, ce, &mut matches);
161                }
162            } else {
163                // Slide the literal needle non-overlapping, folding each hay char at compare time.
164                let mut i = 0;
165                while needle.len() <= hay.len() && i + needle.len() <= hay.len() {
166                    let hit = hay[i..i + needle.len()]
167                        .iter()
168                        .enumerate()
169                        .all(|(k, &c)| fold(c) == needle[k]);
170                    if hit {
171                        let before = matches.len();
172                        push_range(i, i + needle.len(), &mut matches);
173                        // Advance past a real (accepted) match; a whole-word-rejected run advances by
174                        // one so a later, word-bounded position at an overlapping offset is still tried.
175                        i += if matches.len() > before {
176                            needle.len()
177                        } else {
178                            1
179                        };
180                    } else {
181                        i += 1;
182                    }
183                }
184            }
185            r = line + 1;
186        }
187        matches
188    }
189
190    /// Scroll the viewport so a match's start line is visible (placed at the top
191    /// when it sits in history; the live view when it is already on screen).
192    pub fn search_scroll_to(&mut self, m: &Match) {
193        let target = self.scrollback.len().saturating_sub(m.start_line);
194        self.set_display_offset(target);
195    }
196
197    /// Project a match onto the current viewport as inclusive-column spans, one
198    /// per visible row (off-screen parts dropped) — for the renderer to
199    /// highlight, like `selection_range`.
200    ///
201    /// **Both column ends are bounded here, and the `left` half is not an accident of
202    /// symmetry (#678).** A [`Match`]'s columns are *consumer-supplied* by design:
203    /// [`Term::set_active_search_match`] documents taking one the caller assembled
204    /// outside the engine's own result set (the past-cap path, #436), and `Match`'s
205    /// fields are public. So the usual guarantee — "the engine found it, therefore it is
206    /// in range" — does not hold on this path, and only the *index* form
207    /// ([`Term::set_active_search_highlight`]) keeps it by construction.
208    ///
209    /// Left unbounded, a start column past the last one made `right >= left` fail on the
210    /// match's **own** row and dropped it, so a multi-row match lost its first row while
211    /// the rest painted — the shape that reads as "the highlight is fine" at a glance,
212    /// and the reason this was not a visible defect for as long as it existed.
213    ///
214    /// Bounded **here** rather than at the three storing intakes. #671 is the sibling but
215    /// **not** the same shape: it did not touch `selection_range`, whose `left` is still
216    /// unbounded — it clamped selection's *producer* (`Term::viewport_to_abs`), which made
217    /// the read-site asymmetry unreachable. Search has no producer to clamp, because the
218    /// coordinate **is** the consumer's, which is exactly why the same asymmetry stayed
219    /// live here. `right` is already bounded in this expression, so the bound restores a
220    /// symmetry rather than adding a rule, and the write side is three intakes, one taking
221    /// a whole `Vec`. The bound is the row's extent, which is the *grid* width — both row
222    /// producers resize every row to `grid.cols()` — so a short line does not shrink a
223    /// match reaching past its text.
224    ///
225    /// **The references split 1–1 on the guard, and clamping is the chosen side, not the
226    /// obvious one.** alacritty clamps a column unconditionally (`Point::grid_clamp`, run
227    /// on both endpoints before any per-type arithmetic); xterm **hides** — its decoration
228    /// renderer carries a commented arm for precisely this input (*"exceeded the container
229    /// width, so hide"*), which is justerm's *old* outcome. What breaks the tie is that the
230    /// old outcome was neither: it dropped one row and painted the rest. The cost of
231    /// clamping is recorded with it in `reference-facts.md` — on a grid ending in a wide
232    /// glyph the clamped column can be the pair's trailing spacer, so a span can cover half
233    /// a glyph (the #454 class), which hiding would not have produced.
234    pub fn match_spans(&self, m: &Match) -> Vec<SelectionSpan> {
235        let rows = self.grid.rows();
236        let top = self.scrollback.len() - self.display_offset;
237        let mut spans = Vec::new();
238        for line in m.start_line..=m.end_line {
239            if line < top {
240                continue;
241            }
242            let row = line - top;
243            if row >= rows {
244                break;
245            }
246            let last = self.abs_line(line).len().saturating_sub(1);
247            let left = if line == m.start_line {
248                m.start_col.min(last)
249            } else {
250                0
251            };
252            let right = if line == m.end_line {
253                m.end_col.min(last)
254            } else {
255                last
256            };
257            // #454: widen onto whole wide-glyph pairs, the same rule `selection_range` applies. This
258            // surface needs it for a reason the selection does not have — a `Match` may be AUTHORED
259            // by the consumer (`set_active_search_match`, the past-cap path), and #678's clamp lands
260            // an out-of-range column on the last cell, which is a trailing spacer whenever the row
261            // ends in a wide glyph. Measured there: `start_col: 99` on a 6-column row holding
262            // `abcd한` projected to `left: 5, right: 5` — the glyph's right half alone.
263            let (left, right) = (self.pair_start(line, left), self.pair_end(line, right));
264            if right >= left {
265                spans.push(SelectionSpan { row, left, right });
266            }
267        }
268        spans
269    }
270
271    /// Set the search highlights to paint (#108). The consumer owns the
272    /// `Vec<Match>` (it drives next/prev); handing it back here lets `frame()`
273    /// project the highlights onto the viewport. An empty vec clears them.
274    pub fn set_search_highlights(&mut self, matches: Vec<Match>) {
275        self.search_highlights = matches;
276        // A new set voids the designation: a stale index could be accidentally
277        // in range and light wrong content (#428). The consumer re-designates.
278        self.active_search_highlight = None;
279    }
280
281    /// Designate which member of the held highlight set is the *active* match
282    /// (#428) — the one the consumer's next/prev navigation currently points at.
283    /// `frame()` projects it into `overlay.active_match` (it also stays in
284    /// `overlay.matches`; the renderer's ranking resolves the overlap, #424).
285    /// `None` or an out-of-range index projects nothing; the designation resets
286    /// whenever a new set is passed to [`set_search_highlights`](Self::set_search_highlights).
287    /// The index resolves to its span at call time (#436) — both designation
288    /// APIs converge on one stored representation.
289    pub fn set_active_search_highlight(&mut self, index: Option<usize>) {
290        self.active_search_highlight = index.and_then(|i| self.search_highlights.get(i)).copied();
291    }
292
293    /// Designate the *active* match by its absolute span (#436), independent of
294    /// the held highlight set — the past-cap path: a backend that caps its
295    /// hand-over (the documented 1000, xterm's `highlightLimit`) can still give
296    /// the current match its active emphasis, exactly as xterm creates the
297    /// active decoration from the found result outside the capped list. The
298    /// span projects through the same viewport math as any match (wrap-aware);
299    /// it need not be a member of the held set, so past the cap the match
300    /// paints the ACTIVE colour only (no plain highlight underneath). `None`
301    /// clears. Same lifecycle as the index form: reset on every
302    /// [`set_search_highlights`](Self::set_search_highlights) hand-over and on
303    /// any coordinate-shifting invalidation.
304    pub fn set_active_search_match(&mut self, m: Option<Match>) {
305        self.active_search_highlight = m;
306    }
307
308    /// Invalidate the held search highlights (#108). Called wherever a buffer
309    /// mutation shifts the *line* coordinates the matches were found at — cap
310    /// eviction, in-screen region/RI/SU/SD/IL/DL scroll, the accrual
311    /// sub-region scroll (#449 — which also re-anchors selection/markers below
312    /// the margin, `selection_shift_below_margin`), reflow, both alt swaps.
313    /// In-line *column* shifts (ICH/DCH, insert-mode print) and
314    /// in-place erases (ED/EL/ECH, overwrite) deliberately do NOT funnel — the
315    /// set stales in place there, **healed by the consumer's debounced re-search
316    /// on output** (which those mutations are; `justerm-web`'s search controller
317    /// re-runs the active query after a debounce so highlights track the buffer).
318    /// That healing is the whole of the argument, and it is the reason the
319    /// sibling defect in the *marker* surface had to be fixed in the engine
320    /// instead (#750): `command_lines` is pulled on a user action, so nothing
321    /// re-derives it and the wrong answer reproduces forever.
322    ///
323    /// **The comparison to xterm's decorations used to stand here and is now
324    /// half false (#750).** A search highlight in xterm.js is a decoration
325    /// registered on its own marker (`addons/addon-search/src/DecorationManager.ts`),
326    /// and `DecorationService` disposes a decoration with its marker — so ED,
327    /// which calls `Buffer.clearMarkers` through `_resetBufferLine`, **does**
328    /// retire xterm's search highlights. EL, ECH and an overwrite retire
329    /// nothing there, which is the half that still holds. justerm reaches a
330    /// different answer for ED because it split one reference mechanism into two
331    /// (a marker list and this flat set), and the two halves have different
332    /// consumers: only this one has a re-search behind it. Search matches are query-derived
333    /// (the engine holds matches, not the query, and the *set* itself may have
334    /// changed), so unlike the user-authored selection they are dropped rather
335    /// than re-anchored. Clearing avoids painting wrong content for the frame
336    /// between the mutation and the consumer's refresh.
337    pub(super) fn invalidate_search_highlights(&mut self) {
338        self.search_highlights.clear();
339        // #436: the active designation is a stored SPAN, no longer structurally
340        // tied to the set — clear it in the same funnel or it would keep
341        // painting coordinates that now hold arbitrary other text.
342        self.active_search_highlight = None;
343    }
344}
345
346/// Whether the run `hay[i..i+len]` is bounded by non-word characters on both sides — the `\bword\b`
347/// sense for whole-word search (#314). A word char is alphanumeric or `_` (the regex `\w` set),
348/// deliberately distinct from selection's semantic-selection set.
349///
350/// **#545 made that set consumer-injectable and deliberately left this one alone**, so the two
351/// now differ in kind and not only in contents: whole-word *search* is the `\b` sense of the
352/// pattern the consumer already supplied, while word *selection* is a separator list the consumer
353/// supplies separately. Both references that have a whole-word search agree — xterm.js keeps a
354/// hardcoded `NON_WORD_CHARACTERS` in its search addon (`addons/addon-search/src/SearchEngine.ts`)
355/// that is not its configurable `wordSeparator` and is not even equal to it, and alacritty has no
356/// whole-word option at all (the user writes `\b`, so `\w` decides). Threading
357/// `word_separators` in here would make one knob silently redefine `\b`.
358fn word_bounded(hay: &[char], i: usize, len: usize) -> bool {
359    // A word char is alphanumeric, `_`, OR a grapheme-extending mark (width 0: combining marks,
360    // ZWJ, variation selectors) — so a mark attached to a base is never read as a word boundary,
361    // matching the regex `\b` sense (`\w` includes `\p{M}`) and staying consistent across the
362    // literal and regex paths on decomposed graphemes (#314 Lens 1).
363    let is_word = |c: char| c.is_alphanumeric() || c == '_' || c.width() == Some(0);
364    let left = i == 0 || !is_word(hay[i - 1]);
365    let right = i + len == hay.len() || !is_word(hay[i + len]);
366    left && right
367}