Skip to main content

lore/
search.rs

1//! Filtering and ordering the command list.
2//!
3//! A query is split on whitespace and every term has to be found as a
4//! contiguous run of characters. Letting the letters of one term scatter across
5//! a field fills the list with entries that share nothing with what was typed:
6//! `git` otherwise matches `Get-ChildItem` through its g, i and t. A query that
7//! nothing contains therefore matches nothing, so every row on screen holds
8//! what was typed.
9//!
10//! Match quality is the primary sort key and is deliberately coarse. Fine
11//! grained scores reorder neighbouring entries for reasons the user cannot see,
12//! and a picker whose order cannot be predicted breaks the muscle memory it
13//! exists to serve. Frecency only breaks ties inside a bucket, and the shorter
14//! command breaks what frecency cannot, which is every tie on a fresh install.
15//!
16//! The buckets, strongest first: every term starting a word or the field, or
17//! not; then the field the weakest term was found in, command before
18//! description before tags; then whether that term opened the field or a word
19//! inside it. The field outranks the position because the command is what the
20//! user is looking at and typing towards. "git st" is spelled out in
21//! `git status`, and a description that merely begins with "Stage" should not
22//! put `git add` above it.
23//!
24//! Inside the top bucket, a command that begins with the query exactly as
25//! typed comes first. Typing the start of a command is the most direct thing a
26//! user can do, and `git commit` should not lose to `git cherry-pick <commit>`
27//! because a placeholder happens to share its name.
28
29use std::cmp::Ordering;
30use std::collections::HashMap;
31
32use crate::model::{Entry, Layer};
33use crate::store::stats::Score;
34
35/// An entry with its command already resolved for the active shell.
36#[derive(Debug, Clone, Copy)]
37pub struct Candidate<'a> {
38    pub entry: &'a Entry,
39    pub cmd: &'a str,
40}
41
42/// How closely a field matched. Ordered weakest to strongest.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
44enum Quality {
45    /// The term appears somewhere inside a word.
46    Inside,
47    /// The term starts a word.
48    WordStart,
49    /// The field starts with the term.
50    Prefix,
51}
52
53/// Which field matched. Ordered least to most significant.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
55enum Field {
56    Tags,
57    Desc,
58    Cmd,
59}
60
61/// How well a term matched, ordered weakest to strongest.
62///
63/// The fields are compared in declaration order, which is the whole ranking
64/// policy: see the module documentation.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
66struct Hit {
67    /// The term starts the field or a word in it, rather than sitting inside
68    /// a word.
69    whole: bool,
70    field: Field,
71    quality: Quality,
72}
73
74impl Hit {
75    fn new(field: Field, quality: Quality) -> Self {
76        Self {
77            whole: quality != Quality::Inside,
78            field,
79            quality,
80        }
81    }
82}
83
84/// Everything the ordering depends on, highest wins.
85struct Rank {
86    quality: Option<Hit>,
87    /// The command begins with the query, spaces normalised.
88    leads: bool,
89    pinned: bool,
90    /// User entries outrank builtins only while the query is empty. Once the
91    /// user is searching, a builtin may well be exactly what they want.
92    layer: Option<Layer>,
93    frecency: f64,
94    /// Length of the command in characters. Shorter wins.
95    length: usize,
96}
97
98/// Indices into `candidates`, best first, with non-matching entries removed.
99pub fn rank(
100    candidates: &[Candidate<'_>],
101    scores: &HashMap<String, Score>,
102    query: &str,
103) -> Vec<usize> {
104    let terms = terms(query);
105    let phrase = terms.join(" ");
106    let mut ranked: Vec<(usize, Rank)> = Vec::with_capacity(candidates.len());
107
108    for (index, candidate) in candidates.iter().enumerate() {
109        let quality = if terms.is_empty() {
110            None
111        } else if let Some(quality) = all_terms(candidate, &terms) {
112            Some(quality)
113        } else {
114            continue;
115        };
116
117        let score = scores.get(&candidate.entry.id);
118
119        ranked.push((
120            index,
121            Rank {
122                quality,
123                leads: !phrase.is_empty() && starts_with_ignore_case(candidate.cmd, &phrase),
124                pinned: score.is_some_and(|s| s.pinned),
125                layer: quality.is_none().then_some(candidate.entry.layer),
126                frecency: score.map(|s| s.value).unwrap_or_default(),
127                length: candidate.cmd.chars().count(),
128            },
129        ));
130    }
131
132    ranked.sort_by(|(left_index, left), (right_index, right)| {
133        compare(left, right).then_with(|| {
134            // Ids are unique and the input is id-ordered, so this makes the
135            // result stable rather than merely deterministic.
136            candidates[*left_index]
137                .entry
138                .id
139                .cmp(&candidates[*right_index].entry.id)
140        })
141    });
142
143    ranked.into_iter().map(|(index, _)| index).collect()
144}
145
146/// Character positions in `haystack` that the query matched, for highlighting.
147///
148/// Called only for the rows actually on screen, so its cost does not scale with
149/// the size of the library.
150pub fn highlight(haystack: &str, query: &str) -> Vec<u32> {
151    let mut found: Vec<u32> = terms(query)
152        .iter()
153        .filter_map(|term| find(haystack, term))
154        .flat_map(|(_, covered)| covered)
155        .collect();
156
157    found.sort_unstable();
158    found.dedup();
159    found
160}
161
162/// Whether `haystack` holds every term in `query`.
163///
164/// The same rule `rank` applies to an entry, so a filter typed over plain text
165/// behaves the way the list does.
166pub fn matches(haystack: &str, query: &str) -> bool {
167    terms(query)
168        .iter()
169        .all(|term| find(haystack, term).is_some())
170}
171
172fn terms(query: &str) -> Vec<String> {
173    query
174        .split_whitespace()
175        .map(|term| term.to_lowercase())
176        .collect()
177}
178
179/// The weakest match among the terms, or `None` if any term is missing.
180///
181/// Taking the weakest is what keeps an entry that only mentions one term in its
182/// tags below an entry whose command contains them all.
183fn all_terms(candidate: &Candidate<'_>, terms: &[String]) -> Option<Hit> {
184    let tags = candidate.entry.tags.join(" ");
185    let fields = [
186        (Field::Cmd, candidate.cmd),
187        (Field::Desc, candidate.entry.desc.as_str()),
188        (Field::Tags, tags.as_str()),
189    ];
190
191    terms
192        .iter()
193        .map(|term| {
194            fields
195                .iter()
196                .filter_map(|(field, haystack)| {
197                    quality_of(haystack, term).map(|quality| Hit::new(*field, quality))
198                })
199                .max()
200        })
201        .try_fold(None, |weakest: Option<Hit>, best| {
202            let best = best?;
203            Some(Some(match weakest {
204                Some(weakest) => weakest.min(best),
205                None => best,
206            }))
207        })
208        .flatten()
209}
210
211fn quality_of(haystack: &str, term: &str) -> Option<Quality> {
212    let (at, _) = find(haystack, term)?;
213
214    if at == 0 {
215        return Some(Quality::Prefix);
216    }
217    if starts_word(haystack, at) {
218        return Some(Quality::WordStart);
219    }
220    Some(Quality::Inside)
221}
222
223/// The byte offset where `needle` first occurs, with the character positions it
224/// covers.
225fn find(haystack: &str, needle: &str) -> Option<(usize, Vec<u32>)> {
226    if needle.is_empty() {
227        return None;
228    }
229
230    let length = needle.chars().count();
231    haystack
232        .char_indices()
233        .enumerate()
234        .find(|(_, (offset, _))| starts_with_ignore_case(&haystack[*offset..], needle))
235        .map(|(position, (offset, _))| {
236            let covered = (position..position + length).map(|n| n as u32).collect();
237            (offset, covered)
238        })
239}
240
241/// Whether the character before `at` ends a word, making `at` the start of one.
242fn starts_word(haystack: &str, at: usize) -> bool {
243    haystack[..at]
244        .chars()
245        .next_back()
246        .is_none_or(|previous| !previous.is_alphanumeric())
247}
248
249fn starts_with_ignore_case(haystack: &str, needle: &str) -> bool {
250    let mut haystack = haystack.chars().flat_map(char::to_lowercase);
251    let mut needle = needle.chars().flat_map(char::to_lowercase);
252
253    loop {
254        match (needle.next(), haystack.next()) {
255            (None, _) => return true,
256            (Some(_), None) => return false,
257            (Some(wanted), Some(found)) if wanted != found => return false,
258            _ => {}
259        }
260    }
261}
262
263fn compare(left: &Rank, right: &Rank) -> Ordering {
264    right
265        .quality
266        .cmp(&left.quality)
267        .then(right.leads.cmp(&left.leads))
268        .then(right.pinned.cmp(&left.pinned))
269        .then(right.layer.cmp(&left.layer))
270        .then(right.frecency.total_cmp(&left.frecency))
271        .then(left.length.cmp(&right.length))
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::model::{CommandBody, Entry, Layer};
278    use std::collections::BTreeMap;
279
280    fn entry(id: &str, cmd: &str, desc: &str, tags: &[&str], layer: Layer) -> Entry {
281        Entry {
282            id: id.to_string(),
283            cmd: CommandBody::Shared(cmd.to_string()),
284            desc: desc.to_string(),
285            tags: tags.iter().map(|t| t.to_string()).collect(),
286            params: BTreeMap::new(),
287            danger: false,
288            layer,
289        }
290    }
291
292    fn candidates(entries: &[Entry]) -> Vec<Candidate<'_>> {
293        entries
294            .iter()
295            .map(|entry| Candidate {
296                entry,
297                cmd: match &entry.cmd {
298                    CommandBody::Shared(cmd) => cmd.as_str(),
299                    CommandBody::PerShell(_) => unreachable!("test entries are shared"),
300                },
301            })
302            .collect()
303    }
304
305    fn scored(pairs: &[(&str, f64)]) -> HashMap<String, Score> {
306        pairs
307            .iter()
308            .map(|(id, value)| {
309                (
310                    id.to_string(),
311                    Score {
312                        value: *value,
313                        pinned: false,
314                    },
315                )
316            })
317            .collect()
318    }
319
320    fn order<'a>(
321        entries: &'a [Entry],
322        scores: &HashMap<String, Score>,
323        query: &str,
324    ) -> Vec<&'a str> {
325        let candidates = candidates(entries);
326        rank(&candidates, scores, query)
327            .into_iter()
328            .map(|index| candidates[index].entry.id.as_str())
329            .collect()
330    }
331
332    fn sample() -> Vec<Entry> {
333        vec![
334            entry(
335                "docker.ps",
336                "docker ps -a",
337                "List containers",
338                &["docker"],
339                Layer::Builtin,
340            ),
341            entry(
342                "git.log",
343                "git log --oneline",
344                "Show history",
345                &["git"],
346                Layer::Builtin,
347            ),
348            entry(
349                "git.push",
350                "git push --force-with-lease",
351                "Publish the branch",
352                &["git"],
353                Layer::User,
354            ),
355        ]
356    }
357
358    #[test]
359    fn an_empty_query_keeps_everything() {
360        let entries = sample();
361        assert_eq!(order(&entries, &HashMap::new(), "").len(), 3);
362    }
363
364    #[test]
365    fn an_empty_query_puts_user_entries_before_builtins() {
366        let entries = sample();
367        assert_eq!(order(&entries, &HashMap::new(), "")[0], "git.push");
368    }
369
370    #[test]
371    fn an_empty_query_ranks_by_frecency_within_a_layer() {
372        let entries = sample();
373        let scores = scored(&[("git.log", 5.0), ("docker.ps", 1.0)]);
374        assert_eq!(
375            order(&entries, &scores, ""),
376            ["git.push", "git.log", "docker.ps"]
377        );
378    }
379
380    #[test]
381    fn a_query_filters_out_entries_that_do_not_match() {
382        let entries = sample();
383        assert_eq!(order(&entries, &HashMap::new(), "docker"), ["docker.ps"]);
384    }
385
386    #[test]
387    fn scattered_letters_do_not_count_as_a_match() {
388        let entries = vec![
389            entry(
390                "sys.list",
391                "Get-ChildItem -Path <dir> -Recurse",
392                "Find files under a directory",
393                &[],
394                Layer::Builtin,
395            ),
396            entry(
397                "git.log",
398                "git log --oneline",
399                "Show history",
400                &[],
401                Layer::Builtin,
402            ),
403        ];
404        // Get-ChildItem carries a g, an i and a t, but never "git" together.
405        assert_eq!(order(&entries, &HashMap::new(), "git"), ["git.log"]);
406    }
407
408    #[test]
409    fn letters_are_never_gathered_from_separate_words() {
410        let entries = sample();
411        // "docker ps" spells out d, p and s in order, across two words.
412        assert!(order(&entries, &HashMap::new(), "dps").is_empty());
413    }
414
415    #[test]
416    fn every_term_has_to_be_found() {
417        let entries = vec![
418            entry(
419                "git.clean",
420                "git clean -nfdx",
421                "Preview a clean",
422                &[],
423                Layer::Builtin,
424            ),
425            entry(
426                "git.log",
427                "git log --oneline",
428                "Show history",
429                &[],
430                Layer::Builtin,
431            ),
432        ];
433        assert_eq!(order(&entries, &HashMap::new(), "git cl"), ["git.clean"]);
434    }
435
436    #[test]
437    fn terms_may_land_in_different_fields() {
438        let entries = vec![entry(
439            "docker.logs",
440            "docker logs -f <container>",
441            "Follow the output of a running container",
442            &["debug"],
443            Layer::Builtin,
444        )];
445        // "docker" from the command, "running" from the description.
446        assert_eq!(
447            order(&entries, &HashMap::new(), "docker running"),
448            ["docker.logs"]
449        );
450    }
451
452    #[test]
453    fn a_term_inside_a_word_still_matches() {
454        let entries = sample();
455        assert_eq!(order(&entries, &HashMap::new(), "onelin"), ["git.log"]);
456    }
457
458    #[test]
459    fn a_query_nothing_contains_matches_nothing() {
460        let entries = sample();
461        assert!(order(&entries, &HashMap::new(), "zzzzq").is_empty());
462    }
463
464    #[test]
465    fn match_quality_outranks_frecency() {
466        let entries = sample();
467        let scores = scored(&[("docker.ps", 500.0)]);
468        let ranked = order(&entries, &scores, "git");
469        assert!(!ranked.contains(&"docker.ps"));
470    }
471
472    #[test]
473    fn match_quality_outranks_the_user_layer() {
474        let entries = vec![
475            entry(
476                "user.thing",
477                "kubectl describe thing",
478                "Describe a thing",
479                &[],
480                Layer::User,
481            ),
482            entry(
483                "builtin.kubectl",
484                "kubectl get pods",
485                "List pods",
486                &[],
487                Layer::Builtin,
488            ),
489        ];
490        assert_eq!(
491            order(&entries, &HashMap::new(), "kubectl get")[0],
492            "builtin.kubectl"
493        );
494    }
495
496    #[test]
497    fn frecency_breaks_ties_inside_a_quality_bucket() {
498        let entries = sample();
499        let scores = scored(&[("git.push", 1.0), ("git.log", 9.0)]);
500        assert_eq!(order(&entries, &scores, "git"), ["git.log", "git.push"]);
501    }
502
503    #[test]
504    fn a_command_match_outranks_a_description_match() {
505        let entries = vec![
506            entry(
507                "by.desc",
508                "ls -la",
509                "show docker containers",
510                &[],
511                Layer::Builtin,
512            ),
513            entry(
514                "by.cmd",
515                "docker ps",
516                "list running things",
517                &[],
518                Layer::Builtin,
519            ),
520        ];
521        assert_eq!(order(&entries, &HashMap::new(), "docker")[0], "by.cmd");
522    }
523
524    #[test]
525    fn a_description_match_finds_a_command_by_intent() {
526        let entries = sample();
527        assert_eq!(order(&entries, &HashMap::new(), "history"), ["git.log"]);
528    }
529
530    #[test]
531    fn a_tag_match_still_finds_the_entry() {
532        let entries = vec![entry(
533            "sys.ports",
534            "ss -tulpn",
535            "Show listening sockets",
536            &["network", "troubleshooting"],
537            Layer::Builtin,
538        )];
539        assert_eq!(order(&entries, &HashMap::new(), "network"), ["sys.ports"]);
540    }
541
542    #[test]
543    fn pinning_wins_inside_the_empty_state() {
544        let entries = sample();
545        let mut scores = scored(&[("docker.ps", 0.1)]);
546        scores.get_mut("docker.ps").unwrap().pinned = true;
547        assert_eq!(order(&entries, &scores, "")[0], "docker.ps");
548    }
549
550    #[test]
551    fn pinning_never_overrides_match_quality() {
552        let entries = sample();
553        let mut scores = scored(&[("docker.ps", 100.0)]);
554        scores.get_mut("docker.ps").unwrap().pinned = true;
555        assert!(!order(&entries, &scores, "git").contains(&"docker.ps"));
556    }
557
558    #[test]
559    fn a_word_start_outranks_a_match_inside_a_word() {
560        let entries = vec![
561            // "arg" is buried in the middle of "cargo".
562            entry("inside", "cargo build --release", "", &[], Layer::Builtin),
563            // "arg" opens a word of its own.
564            entry("word.start", "git argocd sync", "", &[], Layer::Builtin),
565        ];
566        assert_eq!(
567            order(&entries, &HashMap::new(), "arg"),
568            ["word.start", "inside"]
569        );
570    }
571
572    /// Every word of "git st" can be found in `git status`. `git add -A`
573    /// only gets there by borrowing "st" from the start of its description,
574    /// which is a weaker claim however early in the field it sits.
575    #[test]
576    fn a_query_found_whole_in_the_command_outranks_one_spread_across_fields() {
577        let entries = vec![
578            entry(
579                "git.add.all",
580                "git add -A",
581                "Stage every change",
582                &[],
583                Layer::Builtin,
584            ),
585            entry(
586                "git.status",
587                "git status",
588                "See what has changed",
589                &[],
590                Layer::Builtin,
591            ),
592        ];
593        assert_eq!(order(&entries, &HashMap::new(), "git st")[0], "git.status");
594    }
595
596    #[test]
597    fn a_command_that_begins_with_the_query_comes_first() {
598        let entries = vec![
599            entry(
600                "git.cherry-pick",
601                "git cherry-pick <commit>",
602                "Copy one commit",
603                &[],
604                Layer::Builtin,
605            ),
606            entry(
607                "git.commit",
608                "git commit -m \"<message>\"",
609                "Record the staged changes",
610                &[],
611                Layer::Builtin,
612            ),
613        ];
614        assert_eq!(
615            order(&entries, &HashMap::new(), "git  commit")[0],
616            "git.commit"
617        );
618    }
619
620    /// Nothing tells two equally good matches apart before either has been
621    /// used. The shorter command is the more basic one, and the one a newcomer
622    /// is more likely to be looking for.
623    #[test]
624    fn the_plainer_command_wins_a_tie() {
625        let entries = vec![
626            entry(
627                "git.stash",
628                "git stash push -u",
629                "Put changes aside",
630                &[],
631                Layer::Builtin,
632            ),
633            entry(
634                "git.status",
635                "git status",
636                "See what has changed",
637                &[],
638                Layer::Builtin,
639            ),
640        ];
641        assert_eq!(order(&entries, &HashMap::new(), "git st")[0], "git.status");
642    }
643
644    #[test]
645    fn a_used_command_still_beats_a_shorter_one() {
646        let entries = vec![
647            entry(
648                "git.stash",
649                "git stash push -u",
650                "Put changes aside",
651                &[],
652                Layer::Builtin,
653            ),
654            entry(
655                "git.status",
656                "git status",
657                "See what has changed",
658                &[],
659                Layer::Builtin,
660            ),
661        ];
662        let scores = scored(&[("git.stash", 1.0)]);
663        assert_eq!(order(&entries, &scores, "git st")[0], "git.stash");
664    }
665
666    /// Queries a first time user types, checked against the library that
667    /// actually ships rather than a hand built sample. The first one is the
668    /// README demo.
669    #[test]
670    fn everyday_queries_find_the_everyday_command_first() {
671        let entries = crate::store::definitions::load(None).unwrap();
672        let candidates: Vec<Candidate<'_>> = entries
673            .iter()
674            .filter_map(|entry| {
675                entry
676                    .cmd_for(crate::model::ShellFamily::Posix)
677                    .map(|cmd| Candidate { entry, cmd })
678            })
679            .collect();
680
681        let expectations = [
682            ("deleted", "git.log.pickaxe"),
683            ("git st", "git.status"),
684            ("git push", "git.push"),
685            ("git commit", "git.commit"),
686            ("docker ps", "docker.ps"),
687            ("docker logs", "docker.logs"),
688            ("kubectl logs", "k8s.logs.follow"),
689            ("disk", "sys.disk.free"),
690        ];
691
692        for (query, expected) in expectations {
693            let first = rank(&candidates, &HashMap::new(), query)
694                .first()
695                .map(|&index| candidates[index].entry.id.as_str());
696            assert_eq!(first, Some(expected), "query {query:?}");
697        }
698    }
699
700    #[test]
701    fn ordering_is_stable_when_nothing_distinguishes_entries() {
702        let entries = sample();
703        let first = order(&entries, &HashMap::new(), "");
704        assert_eq!(first, order(&entries, &HashMap::new(), ""));
705    }
706
707    #[test]
708    fn highlight_marks_the_term_it_found() {
709        assert_eq!(highlight("docker ps", "ps"), [7, 8]);
710        assert_eq!(highlight("docker ps", "docker"), [0, 1, 2, 3, 4, 5]);
711        assert!(highlight("docker ps", "").is_empty());
712    }
713
714    #[test]
715    fn highlight_marks_nothing_it_did_not_match() {
716        assert!(highlight("docker ps", "dps").is_empty());
717    }
718
719    /// Not an assertion: timing thresholds are flaky on shared runners. Run it
720    /// with `cargo test --release -- --ignored --nocapture` to re-measure the
721    /// per-keystroke budget.
722    #[test]
723    #[ignore = "measurement, not a pass or fail"]
724    fn measure_ranking_cost_at_scale() {
725        let entries: Vec<Entry> = (0..2000)
726            .map(|n| {
727                entry(
728                    &format!("ns{}.entry{n}", n % 20),
729                    &format!("kubectl get pods -n namespace{n} -o wide --context cluster{n}"),
730                    &format!("List pods in namespace {n} with node and address columns"),
731                    &["kubernetes", "kubectl", "pods"],
732                    Layer::Builtin,
733                )
734            })
735            .collect();
736
737        let candidates = candidates(&entries);
738        let scores = HashMap::new();
739
740        for query in ["", "k", "ku", "kub", "kube", "pods", "get pods", "zzz"] {
741            let started = std::time::Instant::now();
742            let ranked = rank(&candidates, &scores, query);
743            println!(
744                "query {:>9?}: {:>5} matches in {:>8.3?}",
745                query,
746                ranked.len(),
747                started.elapsed()
748            );
749        }
750    }
751}