strings.rs

  1use std::{
  2    borrow::Borrow,
  3    cmp::Ordering,
  4    iter,
  5    ops::Range,
  6    sync::atomic::{self, AtomicBool},
  7};
  8
  9use gpui::{BackgroundExecutor, SharedString};
 10use nucleo::Utf32Str;
 11use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization};
 12
 13use crate::{
 14    Cancelled, Case, LengthPenalty,
 15    matcher::{self, LENGTH_PENALTY},
 16    positions_from_sorted,
 17};
 18use fuzzy::CharBag;
 19
 20// String matching is always case-insensitive at the nucleo level — using
 21// `CaseMatching::Smart` there would reject queries whose capitalization
 22// doesn't match the candidate, breaking pickers like the command palette
 23// (`"Editor: Backspace"` against the action named `"editor: backspace"`).
 24// `Case::Smart` is still honored as a *scoring hint*: when the query
 25// contains uppercase, candidates whose matched characters disagree in case
 26// are downranked rather than dropped.
 27const SMART_CASE_PENALTY_PER_MISMATCH: f64 = 0.9;
 28
 29struct Query {
 30    atoms: Vec<Atom>,
 31    source_words: Option<Vec<Vec<char>>>,
 32    char_bag: CharBag,
 33}
 34
 35impl Query {
 36    fn build(query: &str, case: Case) -> Option<Self> {
 37        let mut atoms = Vec::new();
 38        let mut source_words = Vec::new();
 39        let wants_case_penalty = case.is_smart() && query.chars().any(|c| c.is_uppercase());
 40
 41        for word in query.split_whitespace() {
 42            atoms.push(Atom::new(
 43                word,
 44                CaseMatching::Ignore,
 45                Normalization::Smart,
 46                AtomKind::Fuzzy,
 47                false,
 48            ));
 49            if wants_case_penalty {
 50                source_words.push(word.chars().collect());
 51            }
 52        }
 53
 54        if atoms.is_empty() {
 55            return None;
 56        }
 57
 58        Some(Query {
 59            atoms,
 60            source_words: wants_case_penalty.then_some(source_words),
 61            char_bag: CharBag::from(query),
 62        })
 63    }
 64}
 65
 66#[derive(Clone, Debug)]
 67pub struct StringMatchCandidate {
 68    pub id: usize,
 69    pub string: SharedString,
 70    char_bag: CharBag,
 71}
 72
 73impl StringMatchCandidate {
 74    pub fn new(id: usize, string: impl ToString) -> Self {
 75        Self::from_shared(id, SharedString::new(string.to_string()))
 76    }
 77
 78    pub fn from_shared(id: usize, string: SharedString) -> Self {
 79        let char_bag = CharBag::from(string.as_ref());
 80        Self {
 81            id,
 82            string,
 83            char_bag,
 84        }
 85    }
 86}
 87
 88#[derive(Clone, Debug)]
 89pub struct StringMatch {
 90    pub candidate_id: usize,
 91    pub score: f64,
 92    pub positions: Vec<usize>,
 93    pub string: SharedString,
 94}
 95
 96impl StringMatch {
 97    pub fn ranges(&self) -> impl '_ + Iterator<Item = Range<usize>> {
 98        let mut positions = self.positions.iter().peekable();
 99        iter::from_fn(move || {
100            let start = *positions.next()?;
101            let char_len = self.char_len_at_index(start)?;
102            let mut end = start + char_len;
103            while let Some(next_start) = positions.peek() {
104                if end == **next_start {
105                    let Some(char_len) = self.char_len_at_index(end) else {
106                        break;
107                    };
108                    end += char_len;
109                    positions.next();
110                } else {
111                    break;
112                }
113            }
114            Some(start..end)
115        })
116    }
117
118    fn char_len_at_index(&self, ix: usize) -> Option<usize> {
119        self.string
120            .get(ix..)
121            .and_then(|slice| slice.chars().next().map(|c| c.len_utf8()))
122    }
123}
124
125impl PartialEq for StringMatch {
126    fn eq(&self, other: &Self) -> bool {
127        self.cmp(other).is_eq()
128    }
129}
130
131impl Eq for StringMatch {}
132
133impl PartialOrd for StringMatch {
134    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
135        Some(self.cmp(other))
136    }
137}
138
139impl Ord for StringMatch {
140    fn cmp(&self, other: &Self) -> Ordering {
141        self.score
142            .total_cmp(&other.score)
143            .then_with(|| self.candidate_id.cmp(&other.candidate_id))
144    }
145}
146
147pub async fn match_strings_async<T>(
148    candidates: &[T],
149    query: &str,
150    case: Case,
151    length_penalty: LengthPenalty,
152    max_results: usize,
153    cancel_flag: &AtomicBool,
154    executor: BackgroundExecutor,
155) -> Vec<StringMatch>
156where
157    T: Borrow<StringMatchCandidate> + Sync,
158{
159    if candidates.is_empty() || max_results == 0 {
160        return Vec::new();
161    }
162
163    let Some(query) = Query::build(query, case) else {
164        return empty_query_results(candidates, max_results);
165    };
166
167    let num_cpus = executor.num_cpus().min(candidates.len());
168    let base_size = candidates.len() / num_cpus;
169    let remainder = candidates.len() % num_cpus;
170    let mut segment_results = (0..num_cpus)
171        .map(|_| Vec::with_capacity(max_results.min(candidates.len())))
172        .collect::<Vec<_>>();
173
174    let config = nucleo::Config::DEFAULT;
175    let mut matchers = matcher::get_matchers(num_cpus, config);
176
177    executor
178        .scoped(|scope| {
179            for (segment_idx, (results, matcher)) in segment_results
180                .iter_mut()
181                .zip(matchers.iter_mut())
182                .enumerate()
183            {
184                let query = &query;
185                scope.spawn(async move {
186                    let segment_start = segment_idx * base_size + segment_idx.min(remainder);
187                    let segment_end =
188                        (segment_idx + 1) * base_size + (segment_idx + 1).min(remainder);
189
190                    match_string_helper(
191                        &candidates[segment_start..segment_end],
192                        query,
193                        matcher,
194                        length_penalty,
195                        results,
196                        cancel_flag,
197                    )
198                    .ok();
199                });
200            }
201        })
202        .await;
203
204    matcher::return_matchers(matchers);
205
206    if cancel_flag.load(atomic::Ordering::Acquire) {
207        return Vec::new();
208    }
209
210    let mut results = segment_results.concat();
211    util::truncate_to_bottom_n_sorted_by(&mut results, max_results, &|a, b| b.cmp(a));
212    results
213}
214
215pub fn match_strings<T>(
216    candidates: &[T],
217    query: &str,
218    case: Case,
219    length_penalty: LengthPenalty,
220    max_results: usize,
221) -> Vec<StringMatch>
222where
223    T: Borrow<StringMatchCandidate>,
224{
225    if candidates.is_empty() || max_results == 0 {
226        return Vec::new();
227    }
228
229    let Some(query) = Query::build(query, case) else {
230        return empty_query_results(candidates, max_results);
231    };
232
233    let config = nucleo::Config::DEFAULT;
234    let mut matcher = matcher::get_matcher(config);
235    let mut results = Vec::with_capacity(max_results.min(candidates.len()));
236
237    match_string_helper(
238        candidates,
239        &query,
240        &mut matcher,
241        length_penalty,
242        &mut results,
243        &AtomicBool::new(false),
244    )
245    .ok();
246
247    matcher::return_matcher(matcher);
248    util::truncate_to_bottom_n_sorted_by(&mut results, max_results, &|a, b| b.cmp(a));
249    results
250}
251
252fn empty_query_results<T: Borrow<StringMatchCandidate>>(
253    candidates: &[T],
254    max_results: usize,
255) -> Vec<StringMatch> {
256    candidates
257        .iter()
258        .take(max_results)
259        .map(|candidate| {
260            let borrowed = candidate.borrow();
261            StringMatch {
262                candidate_id: borrowed.id,
263                score: 0.,
264                positions: Vec::new(),
265                string: borrowed.string.clone(),
266            }
267        })
268        .collect()
269}
270
271fn match_string_helper<T>(
272    candidates: &[T],
273    query: &Query,
274    matcher: &mut nucleo::Matcher,
275    length_penalty: LengthPenalty,
276    results: &mut Vec<StringMatch>,
277    cancel_flag: &AtomicBool,
278) -> Result<(), Cancelled>
279where
280    T: Borrow<StringMatchCandidate>,
281{
282    let mut buf = Vec::new();
283    let mut matched_chars: Vec<u32> = Vec::new();
284    let mut atom_matched_chars = Vec::new();
285    let mut candidate_chars: Vec<char> = Vec::new();
286
287    for candidate in candidates {
288        buf.clear();
289        matched_chars.clear();
290        if cancel_flag.load(atomic::Ordering::Relaxed) {
291            return Err(Cancelled);
292        }
293
294        let borrowed = candidate.borrow();
295
296        if !borrowed.char_bag.is_superset(query.char_bag) {
297            continue;
298        }
299
300        let haystack: Utf32Str = Utf32Str::new(&borrowed.string, &mut buf);
301
302        if query.source_words.is_some() {
303            candidate_chars.clear();
304            candidate_chars.extend(borrowed.string.chars());
305        }
306
307        let mut total_score: u32 = 0;
308        let mut case_mismatches: u32 = 0;
309        let mut all_matched = true;
310
311        for (atom_idx, atom) in query.atoms.iter().enumerate() {
312            atom_matched_chars.clear();
313            let Some(score) = atom.indices(haystack, matcher, &mut atom_matched_chars) else {
314                all_matched = false;
315                break;
316            };
317            total_score = total_score.saturating_add(score as u32);
318            if let Some(source_words) = query.source_words.as_deref() {
319                let query_chars = &source_words[atom_idx];
320                if query_chars.len() == atom_matched_chars.len() {
321                    for (&query_char, &pos) in query_chars.iter().zip(&atom_matched_chars) {
322                        if let Some(&candidate_char) = candidate_chars.get(pos as usize)
323                            && candidate_char != query_char
324                            && candidate_char.eq_ignore_ascii_case(&query_char)
325                        {
326                            case_mismatches += 1;
327                        }
328                    }
329                }
330            }
331            matched_chars.extend_from_slice(&atom_matched_chars);
332        }
333
334        if all_matched {
335            matched_chars.sort_unstable();
336            matched_chars.dedup();
337
338            let positive = total_score as f64 * case_penalty(case_mismatches);
339            let adjusted_score =
340                positive - length_penalty_for(borrowed.string.as_ref(), length_penalty);
341            let positions = positions_from_sorted(borrowed.string.as_ref(), &matched_chars);
342
343            results.push(StringMatch {
344                candidate_id: borrowed.id,
345                score: adjusted_score,
346                positions,
347                string: borrowed.string.clone(),
348            });
349        }
350    }
351    Ok(())
352}
353
354#[inline]
355fn case_penalty(mismatches: u32) -> f64 {
356    if mismatches == 0 {
357        1.0
358    } else {
359        SMART_CASE_PENALTY_PER_MISMATCH.powi(mismatches as i32)
360    }
361}
362
363#[inline]
364fn length_penalty_for(s: &str, length_penalty: LengthPenalty) -> f64 {
365    if length_penalty.is_on() {
366        s.len() as f64 * LENGTH_PENALTY
367    } else {
368        0.0
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use gpui::BackgroundExecutor;
376
377    fn candidates(strings: &[&str]) -> Vec<StringMatchCandidate> {
378        strings
379            .iter()
380            .enumerate()
381            .map(|(id, s)| StringMatchCandidate::new(id, s))
382            .collect()
383    }
384
385    #[gpui::test]
386    async fn test_basic_match(executor: BackgroundExecutor) {
387        let cs = candidates(&["hello", "world", "help"]);
388        let cancel = AtomicBool::new(false);
389        let results = match_strings_async(
390            &cs,
391            "hel",
392            Case::Ignore,
393            LengthPenalty::Off,
394            10,
395            &cancel,
396            executor,
397        )
398        .await;
399        let matched: Vec<&str> = results.iter().map(|m| m.string.as_ref()).collect();
400        assert!(matched.contains(&"hello"));
401        assert!(matched.contains(&"help"));
402        assert!(!matched.contains(&"world"));
403    }
404
405    #[gpui::test]
406    async fn test_multi_word_query(executor: BackgroundExecutor) {
407        let cs = candidates(&[
408            "src/lib/parser.rs",
409            "src/bin/main.rs",
410            "tests/parser_test.rs",
411        ]);
412        let cancel = AtomicBool::new(false);
413        let results = match_strings_async(
414            &cs,
415            "src parser",
416            Case::Ignore,
417            LengthPenalty::Off,
418            10,
419            &cancel,
420            executor,
421        )
422        .await;
423        assert_eq!(results.len(), 1);
424        assert_eq!(results[0].string, "src/lib/parser.rs");
425    }
426
427    #[gpui::test]
428    async fn test_empty_query_returns_all(executor: BackgroundExecutor) {
429        let cs = candidates(&["alpha", "beta", "gamma"]);
430        let cancel = AtomicBool::new(false);
431        let results = match_strings_async(
432            &cs,
433            "",
434            Case::Ignore,
435            LengthPenalty::Off,
436            10,
437            &cancel,
438            executor,
439        )
440        .await;
441        assert_eq!(results.len(), 3);
442        assert!(results.iter().all(|m| m.score == 0.0));
443    }
444
445    #[gpui::test]
446    async fn test_whitespace_only_query_returns_all(executor: BackgroundExecutor) {
447        let cs = candidates(&["alpha", "beta", "gamma"]);
448        let cancel = AtomicBool::new(false);
449        let results = match_strings_async(
450            &cs,
451            "   \t\n",
452            Case::Ignore,
453            LengthPenalty::Off,
454            10,
455            &cancel,
456            executor,
457        )
458        .await;
459        assert_eq!(results.len(), 3);
460    }
461
462    #[gpui::test]
463    async fn test_empty_candidates(executor: BackgroundExecutor) {
464        let cs: Vec<StringMatchCandidate> = vec![];
465        let cancel = AtomicBool::new(false);
466        let results = match_strings_async(
467            &cs,
468            "query",
469            Case::Ignore,
470            LengthPenalty::Off,
471            10,
472            &cancel,
473            executor,
474        )
475        .await;
476        assert!(results.is_empty());
477    }
478
479    #[gpui::test]
480    async fn test_cancellation(executor: BackgroundExecutor) {
481        let cs = candidates(&["hello", "world"]);
482        let cancel = AtomicBool::new(true);
483        let results = match_strings_async(
484            &cs,
485            "hel",
486            Case::Ignore,
487            LengthPenalty::Off,
488            10,
489            &cancel,
490            executor,
491        )
492        .await;
493        assert!(results.is_empty());
494    }
495
496    #[gpui::test]
497    async fn test_max_results_limit(executor: BackgroundExecutor) {
498        let cs = candidates(&["ab", "abc", "abcd", "abcde"]);
499        let cancel = AtomicBool::new(false);
500        let results = match_strings_async(
501            &cs,
502            "ab",
503            Case::Ignore,
504            LengthPenalty::Off,
505            2,
506            &cancel,
507            executor,
508        )
509        .await;
510        assert_eq!(results.len(), 2);
511    }
512
513    #[gpui::test]
514    async fn test_scoring_order(executor: BackgroundExecutor) {
515        let cs = candidates(&[
516            "some_very_long_variable_name_fuzzy",
517            "fuzzy",
518            "a_fuzzy_thing",
519        ]);
520        let cancel = AtomicBool::new(false);
521        let results = match_strings_async(
522            &cs,
523            "fuzzy",
524            Case::Ignore,
525            LengthPenalty::Off,
526            10,
527            &cancel,
528            executor.clone(),
529        )
530        .await;
531
532        let ordered = matches!(
533            (
534                results[0].string.as_ref(),
535                results[1].string.as_ref(),
536                results[2].string.as_ref()
537            ),
538            (
539                "fuzzy",
540                "a_fuzzy_thing",
541                "some_very_long_variable_name_fuzzy"
542            )
543        );
544        assert!(ordered, "matches are not in the proper order.");
545
546        let results_penalty = match_strings_async(
547            &cs,
548            "fuzzy",
549            Case::Ignore,
550            LengthPenalty::On,
551            10,
552            &cancel,
553            executor,
554        )
555        .await;
556        let greater = results[2].score > results_penalty[2].score;
557        assert!(greater, "penalize length not affecting long candidates");
558    }
559
560    #[gpui::test]
561    async fn test_utf8_positions(executor: BackgroundExecutor) {
562        let cs = candidates(&["café"]);
563        let cancel = AtomicBool::new(false);
564        let results = match_strings_async(
565            &cs,
566            "caf",
567            Case::Ignore,
568            LengthPenalty::Off,
569            10,
570            &cancel,
571            executor,
572        )
573        .await;
574        assert_eq!(results.len(), 1);
575        let m = &results[0];
576        assert_eq!(m.positions, vec![0, 1, 2]);
577        for &pos in &m.positions {
578            assert!(m.string.is_char_boundary(pos));
579        }
580    }
581
582    #[gpui::test]
583    async fn test_smart_case(executor: BackgroundExecutor) {
584        let cs = candidates(&["FooBar", "foobar", "FOOBAR"]);
585        let cancel = AtomicBool::new(false);
586
587        let case_insensitive = match_strings_async(
588            &cs,
589            "foobar",
590            Case::Ignore,
591            LengthPenalty::Off,
592            10,
593            &cancel,
594            executor.clone(),
595        )
596        .await;
597        assert_eq!(case_insensitive.len(), 3);
598
599        let smart = match_strings_async(
600            &cs,
601            "FooBar",
602            Case::Smart,
603            LengthPenalty::Off,
604            10,
605            &cancel,
606            executor,
607        )
608        .await;
609        assert!(smart.iter().any(|m| m.string == "FooBar"));
610        let foobar_score = smart.iter().find(|m| m.string == "FooBar").map(|m| m.score);
611        let lower_score = smart.iter().find(|m| m.string == "foobar").map(|m| m.score);
612        if let (Some(exact), Some(lower)) = (foobar_score, lower_score) {
613            assert!(exact >= lower);
614        }
615    }
616
617    #[gpui::test]
618    async fn test_smart_case_does_not_flip_order_when_length_penalty_on(
619        executor: BackgroundExecutor,
620    ) {
621        // Regression for the sign bug: with a length penalty large enough to push
622        // `total_score - length_penalty` negative, case mismatches used to make
623        // scores *better* (less negative). Exact-case match must still rank first.
624        let cs = candidates(&[
625            "aaaaaaaaaaaaaaaaaaaaaaaaaaaa_FooBar",
626            "aaaaaaaaaaaaaaaaaaaaaaaaaaaa_foobar",
627        ]);
628        let cancel = AtomicBool::new(false);
629        let results = match_strings_async(
630            &cs,
631            "FooBar",
632            Case::Smart,
633            LengthPenalty::On,
634            10,
635            &cancel,
636            executor,
637        )
638        .await;
639        let exact = results
640            .iter()
641            .find(|m| m.string.as_ref() == "aaaaaaaaaaaaaaaaaaaaaaaaaaaa_FooBar")
642            .map(|m| m.score)
643            .expect("exact-case candidate should match");
644        let mismatch = results
645            .iter()
646            .find(|m| m.string.as_ref() == "aaaaaaaaaaaaaaaaaaaaaaaaaaaa_foobar")
647            .map(|m| m.score)
648            .expect("mismatch-case candidate should match");
649        assert!(
650            exact >= mismatch,
651            "exact-case score ({exact}) should be >= mismatch-case score ({mismatch})"
652        );
653    }
654
655    #[gpui::test]
656    async fn test_char_bag_prefilter(executor: BackgroundExecutor) {
657        let cs = candidates(&["abcdef", "abc", "def", "aabbcc"]);
658        let cancel = AtomicBool::new(false);
659        let results = match_strings_async(
660            &cs,
661            "abc",
662            Case::Ignore,
663            LengthPenalty::Off,
664            10,
665            &cancel,
666            executor,
667        )
668        .await;
669        let matched: Vec<&str> = results.iter().map(|m| m.string.as_ref()).collect();
670        assert!(matched.contains(&"abcdef"));
671        assert!(matched.contains(&"abc"));
672        assert!(matched.contains(&"aabbcc"));
673        assert!(!matched.contains(&"def"));
674    }
675
676    #[test]
677    fn test_sync_basic_match() {
678        let cs = candidates(&["hello", "world", "help"]);
679        let results = match_strings(&cs, "hel", Case::Ignore, LengthPenalty::Off, 10);
680        let matched: Vec<&str> = results.iter().map(|m| m.string.as_ref()).collect();
681        assert!(matched.contains(&"hello"));
682        assert!(matched.contains(&"help"));
683        assert!(!matched.contains(&"world"));
684    }
685
686    #[test]
687    fn test_sync_empty_query_returns_all() {
688        let cs = candidates(&["alpha", "beta", "gamma"]);
689        let results = match_strings(&cs, "", Case::Ignore, LengthPenalty::Off, 10);
690        assert_eq!(results.len(), 3);
691    }
692
693    #[test]
694    fn test_sync_whitespace_only_query_returns_all() {
695        let cs = candidates(&["alpha", "beta", "gamma"]);
696        let results = match_strings(&cs, "  ", Case::Ignore, LengthPenalty::Off, 10);
697        assert_eq!(results.len(), 3);
698    }
699
700    #[test]
701    fn test_sync_max_results() {
702        let cs = candidates(&["ab", "abc", "abcd", "abcde"]);
703        let results = match_strings(&cs, "ab", Case::Ignore, LengthPenalty::Off, 2);
704        assert_eq!(results.len(), 2);
705    }
706
707    #[gpui::test]
708    async fn test_empty_query_respects_max_results(executor: BackgroundExecutor) {
709        let cs = candidates(&["alpha", "beta", "gamma", "delta"]);
710        let cancel = AtomicBool::new(false);
711        let results = match_strings_async(
712            &cs,
713            "",
714            Case::Ignore,
715            LengthPenalty::Off,
716            2,
717            &cancel,
718            executor,
719        )
720        .await;
721        assert_eq!(results.len(), 2);
722    }
723
724    #[gpui::test]
725    async fn test_multi_word_with_nonmatching_word(executor: BackgroundExecutor) {
726        let cs = candidates(&["src/parser.rs", "src/main.rs"]);
727        let cancel = AtomicBool::new(false);
728        let results = match_strings_async(
729            &cs,
730            "src xyzzy",
731            Case::Ignore,
732            LengthPenalty::Off,
733            10,
734            &cancel,
735            executor,
736        )
737        .await;
738        assert!(
739            results.is_empty(),
740            "no candidate contains 'xyzzy', so nothing should match"
741        );
742    }
743
744    #[gpui::test]
745    async fn test_segment_size_not_divisible_by_cpus(executor: BackgroundExecutor) {
746        executor.set_num_cpus(4);
747        let cs = candidates(&["alpha", "beta", "gamma", "delta", "epsilon"]);
748        let cancel = AtomicBool::new(false);
749        let results = match_strings_async(
750            &cs,
751            "a",
752            Case::Ignore,
753            LengthPenalty::Off,
754            10,
755            &cancel,
756            executor,
757        )
758        .await;
759        let matched: Vec<&str> = results.iter().map(|m| m.string.as_ref()).collect();
760        assert!(matched.contains(&"alpha"));
761        assert!(matched.contains(&"gamma"));
762        assert!(matched.contains(&"delta"));
763    }
764
765    #[gpui::test]
766    async fn test_segment_size_with_many_cpus_few_candidates(executor: BackgroundExecutor) {
767        executor.set_num_cpus(16);
768        let cs = candidates(&["one", "two", "three"]);
769        let cancel = AtomicBool::new(false);
770        let results = match_strings_async(
771            &cs,
772            "o",
773            Case::Ignore,
774            LengthPenalty::Off,
775            10,
776            &cancel,
777            executor,
778        )
779        .await;
780        let matched: Vec<&str> = results.iter().map(|m| m.string.as_ref()).collect();
781        assert!(matched.contains(&"one"));
782        assert!(matched.contains(&"two"));
783    }
784
785    #[gpui::test]
786    async fn test_segment_size_single_candidate(executor: BackgroundExecutor) {
787        executor.set_num_cpus(8);
788        let cs = candidates(&["lonely"]);
789        let cancel = AtomicBool::new(false);
790        let results = match_strings_async(
791            &cs,
792            "lone",
793            Case::Ignore,
794            LengthPenalty::Off,
795            10,
796            &cancel,
797            executor,
798        )
799        .await;
800        assert_eq!(results.len(), 1);
801        assert_eq!(results[0].string.as_ref(), "lonely");
802    }
803
804    #[gpui::test]
805    async fn test_segment_size_candidates_equal_cpus(executor: BackgroundExecutor) {
806        executor.set_num_cpus(4);
807        let cs = candidates(&["aaa", "bbb", "ccc", "ddd"]);
808        let cancel = AtomicBool::new(false);
809        let results = match_strings_async(
810            &cs,
811            "a",
812            Case::Ignore,
813            LengthPenalty::Off,
814            10,
815            &cancel,
816            executor,
817        )
818        .await;
819        assert_eq!(results.len(), 1);
820        assert_eq!(results[0].string.as_ref(), "aaa");
821    }
822
823    #[gpui::test]
824    async fn test_segment_size_candidates_one_more_than_cpus(executor: BackgroundExecutor) {
825        executor.set_num_cpus(3);
826        let cs = candidates(&["ant", "ape", "dog", "axe"]);
827        let cancel = AtomicBool::new(false);
828        let results = match_strings_async(
829            &cs,
830            "a",
831            Case::Ignore,
832            LengthPenalty::Off,
833            10,
834            &cancel,
835            executor,
836        )
837        .await;
838        let matched: Vec<&str> = results.iter().map(|m| m.string.as_ref()).collect();
839        assert!(matched.contains(&"ant"));
840        assert!(matched.contains(&"ape"));
841        assert!(matched.contains(&"axe"));
842        assert!(!matched.contains(&"dog"));
843    }
844}