Skip to main content

tmprl_core/
search.rs

1//! Searching what is already on screen.
2//!
3//! Deliberately not the same thing as the visibility query, and the difference is worth
4//! stating because the two look similar from the outside. The query is a filter the
5//! *server* applies: it decides which workflows exist as far as this pane is concerned, it
6//! costs a round trip, and it is written in Temporal's list-filter dialect. A search costs
7//! nothing and changes nothing, it moves the cursor to the next row whose text matches, and
8//! it works on screens the query cannot reach at all, the history outline most of all,
9//! where there is no server-side filter to ask for.
10//!
11//! Neither substitutes for the other. You narrow to a few hundred workflows with the query
12//! and then find the one you want with `/`.
13//!
14//! Pure: this module never learns what a row *is*, only the text one renders to. Deciding
15//! that text is the view's job, which keeps this crate free of screens and keeps the
16//! matching unit-testable without building an application.
17
18/// A pattern, with its case sensitivity already decided.
19#[derive(Debug, Clone, PartialEq, Eq, Default)]
20pub struct Search {
21    /// As typed. Kept verbatim so the statusline can echo the search back, the way vim's
22    /// last-search register does.
23    pattern: String,
24    case_sensitive: bool,
25}
26
27/// Where a search landed.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct Hit {
30    pub row: usize,
31    /// The search ran off the end and resumed from the other one. Reported rather than
32    /// silent: vim says "search hit BOTTOM, continuing at TOP", and without it a wrap looks
33    /// like the cursor jumped at random.
34    pub wrapped: bool,
35}
36
37impl Search {
38    /// Compile a pattern. Empty is legal and matches nothing, which is what makes `n` with
39    /// no previous search a no-op rather than a jump to row 0.
40    pub fn new(pattern: impl Into<String>) -> Self {
41        let pattern = pattern.into();
42        let case_sensitive = smartcase(&pattern);
43        Self {
44            pattern,
45            case_sensitive,
46        }
47    }
48
49    pub fn pattern(&self) -> &str {
50        &self.pattern
51    }
52
53    pub fn is_empty(&self) -> bool {
54        self.pattern.is_empty()
55    }
56
57    /// Whether this text contains the pattern anywhere.
58    pub fn matches(&self, haystack: &str) -> bool {
59        if self.pattern.is_empty() {
60            return false;
61        }
62        haystack
63            .char_indices()
64            .any(|(at, _)| self.match_at(haystack, at).is_some())
65    }
66
67    /// Byte ranges of every match, for highlighting.
68    ///
69    /// Offsets are into `haystack` exactly as given, not into a lowercased copy. Case
70    /// folding can change a string's byte length, `İ` lowercases to two chars, so folding
71    /// first and reusing the resulting offsets would slice the original in the wrong place
72    /// and eventually panic on a char boundary. Scanning char by char costs more and is
73    /// always right, and this only ever runs over the rows actually on screen.
74    pub fn spans(&self, haystack: &str) -> Vec<(usize, usize)> {
75        let mut out = Vec::new();
76        if self.pattern.is_empty() {
77            return out;
78        }
79        let starts: Vec<usize> = haystack.char_indices().map(|(i, _)| i).collect();
80        let mut i = 0;
81        while i < starts.len() {
82            match self.match_at(haystack, starts[i]) {
83                Some(end) => {
84                    out.push((starts[i], end));
85                    // Resume past the match. Overlapping spans would double-paint the
86                    // overlap, which renders as a differently-coloured sliver.
87                    while i < starts.len() && starts[i] < end {
88                        i += 1;
89                    }
90                }
91                None => i += 1,
92            }
93        }
94        out
95    }
96
97    /// If `haystack` from `at` begins with the pattern, the byte offset just past it.
98    fn match_at(&self, haystack: &str, at: usize) -> Option<usize> {
99        let mut rest = haystack[at..].chars();
100        for want in self.pattern.chars() {
101            let got = rest.next()?;
102            let same = if self.case_sensitive {
103                got == want
104            } else {
105                got.to_lowercase().eq(want.to_lowercase())
106            };
107            if !same {
108                return None;
109            }
110        }
111        Some(haystack.len() - rest.as_str().len())
112    }
113}
114
115/// Vim's `smartcase`: an all-lowercase pattern ignores case, one with any uppercase in it
116/// does not.
117///
118/// This is the behaviour people have in their fingers, and it is the right default for the
119/// data as well: workflow types and activity names are camel case, so typing `charge`
120/// should find `ChargeCard`, while typing `ChargeCard` means you know what you are after.
121fn smartcase(pattern: &str) -> bool {
122    pattern.chars().any(char::is_uppercase)
123}
124
125/// The next matching row from `from`, wrapping once.
126///
127/// `inclusive` decides whether the row the cursor is on is a candidate. `n` excludes it, so
128/// repeated presses walk the results; a freshly typed `/` includes it, because you have just
129/// typed the pattern while looking at the screen and skipping a match that is right there
130/// reads as not having found it.
131///
132/// Wrapping is unconditional, unlike `]f`, which stops at the end. A failure jump is
133/// "where did this go wrong", asked once; a search is "show me the next one", asked
134/// repeatedly, and a `n` that silently stops at the last match reads as a broken key.
135///
136/// The `inclusive` flag exists rather than letting the caller pass `from - 1`, which is the
137/// obvious trick and is wrong: at `from == 0` it underflows to the last row, and every
138/// subsequent hit then looks like it wrapped. Since 0 is where a freshly loaded pane puts
139/// the cursor, that made essentially every first search claim to have wrapped, which is the
140/// one signal meant to explain a cursor that jumped backwards.
141pub fn find(
142    search: &Search,
143    labels: &[String],
144    from: usize,
145    forward: bool,
146    inclusive: bool,
147) -> Option<Hit> {
148    if search.is_empty() || labels.is_empty() {
149        return None;
150    }
151    let n = labels.len();
152    let offset = if inclusive { 0 } else { 1 };
153
154    // `step` counts how far from the cursor a candidate is, which is what decides both the
155    // row and whether getting to it went round the end. Both directions use the same rule:
156    // a wrap is a step that runs past the edge of the list.
157    (0..n).find_map(|i| {
158        let step = i + offset;
159        let (row, wrapped) = if forward {
160            ((from + step) % n, from + step >= n)
161        } else {
162            ((from + n - (step % n)) % n, step > from)
163        };
164        search.matches(&labels[row]).then_some(Hit { row, wrapped })
165    })
166}
167
168/// Every matching row, for the count in the statusline.
169pub fn count(search: &Search, labels: &[String]) -> usize {
170    if search.is_empty() {
171        return 0;
172    }
173    labels.iter().filter(|l| search.matches(l)).count()
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn labels(v: &[&str]) -> Vec<String> {
181        v.iter().map(|s| s.to_string()).collect()
182    }
183
184    #[test]
185    fn a_lowercase_pattern_ignores_case() {
186        let s = Search::new("charge");
187        assert!(s.matches("ChargeCard"));
188        assert!(s.matches("CHARGE"));
189    }
190
191    #[test]
192    fn a_pattern_with_uppercase_is_case_sensitive() {
193        let s = Search::new("Charge");
194        assert!(s.matches("ChargeCard"));
195        assert!(!s.matches("chargecard"));
196    }
197
198    #[test]
199    fn an_empty_pattern_matches_nothing() {
200        // Otherwise `n` with no previous search jumps to row 0, which reads as the key
201        // being bound to something else entirely.
202        let s = Search::new("");
203        assert!(!s.matches("anything"));
204        assert_eq!(find(&s, &labels(&["a", "b"]), 0, true, false), None);
205    }
206
207    #[test]
208    fn spans_point_into_the_original_string() {
209        let s = Search::new("ab");
210        let hay = "xxabyyab";
211        assert_eq!(s.spans(hay), vec![(2, 4), (6, 8)]);
212        for (a, b) in s.spans(hay) {
213            // The whole point of the byte offsets: they must be sliceable.
214            assert_eq!(&hay[a..b], "ab");
215        }
216    }
217
218    #[test]
219    fn spans_survive_a_multibyte_haystack() {
220        let s = Search::new("é");
221        let hay = "aéb";
222        let spans = s.spans(hay);
223        assert_eq!(spans.len(), 1);
224        let (a, b) = spans[0];
225        assert_eq!(&hay[a..b], "é");
226    }
227
228    #[test]
229    fn overlapping_matches_are_reported_once() {
230        // "aa" in "aaa" starts at 0 and at 1; painting both would double-draw byte 1.
231        let s = Search::new("aa");
232        assert_eq!(s.spans("aaa"), vec![(0, 2)]);
233    }
234
235    #[test]
236    fn find_skips_the_row_the_cursor_is_on() {
237        let rows = labels(&["charge", "ship", "charge"]);
238        let s = Search::new("charge");
239        assert_eq!(find(&s, &rows, 0, true, false).unwrap().row, 2);
240    }
241
242    #[test]
243    fn find_wraps_and_says_so() {
244        let rows = labels(&["charge", "ship", "refund"]);
245        let s = Search::new("charge");
246        let hit = find(&s, &rows, 1, true, false).unwrap();
247        assert_eq!(hit.row, 0);
248        assert!(hit.wrapped, "going forward past the last match wraps");
249    }
250
251    #[test]
252    fn find_backwards_wraps_too() {
253        let rows = labels(&["charge", "ship", "refund"]);
254        let s = Search::new("refund");
255        let hit = find(&s, &rows, 0, false, false).unwrap();
256        assert_eq!(hit.row, 2);
257        assert!(hit.wrapped);
258    }
259
260    #[test]
261    fn a_sole_match_is_found_from_itself_by_wrapping() {
262        // `n` on the only match stays put, and reports the wrap rather than "not found",
263        // which is what vim does.
264        let rows = labels(&["charge", "ship"]);
265        let s = Search::new("charge");
266        let hit = find(&s, &rows, 0, true, false).unwrap();
267        assert_eq!(hit.row, 0);
268        assert!(hit.wrapped);
269    }
270
271    #[test]
272    fn no_match_is_none_rather_than_a_jump_to_zero() {
273        let rows = labels(&["charge", "ship"]);
274        assert_eq!(find(&Search::new("nope"), &rows, 0, true, false), None);
275    }
276
277    #[test]
278    fn an_inclusive_search_from_row_zero_does_not_claim_to_have_wrapped() {
279        // The regression this flag exists for. Row 0 is where a freshly loaded pane puts
280        // the cursor, so getting this wrong made almost every first search say "wrapped".
281        let rows = labels(&["charge", "ship", "refund"]);
282        let hit = find(&Search::new("charge"), &rows, 0, true, true).unwrap();
283        assert_eq!(hit.row, 0);
284        assert!(!hit.wrapped, "row 0 is where we started, not a wrap");
285    }
286
287    #[test]
288    fn an_inclusive_search_matches_the_row_it_starts_on() {
289        let rows = labels(&["charge", "ship", "charge"]);
290        let hit = find(&Search::new("charge"), &rows, 2, true, true).unwrap();
291        assert_eq!(hit.row, 2, "the cursor's own row is a candidate");
292        assert!(!hit.wrapped);
293    }
294
295    #[test]
296    fn an_inclusive_search_still_reports_a_real_wrap() {
297        let rows = labels(&["charge", "ship", "refund"]);
298        let hit = find(&Search::new("charge"), &rows, 1, true, true).unwrap();
299        assert_eq!(hit.row, 0);
300        assert!(hit.wrapped, "going past the end to reach it is a wrap");
301    }
302
303    #[test]
304    fn an_exclusive_search_from_row_zero_reports_a_backward_wrap() {
305        let rows = labels(&["charge", "ship", "refund"]);
306        let hit = find(&Search::new("refund"), &rows, 0, false, false).unwrap();
307        assert_eq!(hit.row, 2);
308        assert!(hit.wrapped);
309    }
310
311    #[test]
312    fn count_reports_every_matching_row() {
313        let rows = labels(&["charge", "ship", "Charge"]);
314        assert_eq!(count(&Search::new("charge"), &rows), 2);
315        assert_eq!(count(&Search::new("Charge"), &rows), 1);
316    }
317}