1use crate::{query::key_kind_allowed, QueryVariant, SearchKey};
2use nucleo_matcher::{chars, Config as NucleoConfig, Matcher, Utf32Str};
3use std::borrow::Cow;
4use unicode_normalization::UnicodeNormalization;
5
6const SCORE_MATCH: i64 = 160;
7const SCORE_GAP_START: i64 = -30;
8const SCORE_GAP_EXTENSION: i64 = -10;
9const BONUS_BOUNDARY: i64 = 80;
10const BONUS_BOUNDARY_WHITE: i64 = 100;
11const BONUS_BOUNDARY_DELIMITER: i64 = 90;
12const BONUS_CAMEL_OR_NUMBER: i64 = 70;
13const BONUS_CONSECUTIVE: i64 = 40;
14pub(crate) const BONUS_CASE_EXACT: i64 = 75;
33const _: () = assert!(
34 BONUS_CASE_EXACT > BONUS_CAMEL_OR_NUMBER && BONUS_CASE_EXACT < BONUS_BOUNDARY,
35 "the exact-case bonus must break a camelCase tie without outranking a word boundary"
36);
37const BONUS_FIRST_CHAR_MULTIPLIER: i64 = 2;
38const START_POSITION_PENALTY: i64 = 2;
39const TEXT_LENGTH_PENALTY_DIVISOR: i64 = 8;
40
41pub trait MatcherBackend {
48 fn score(&mut self, pattern: &str, text: &str) -> Option<i64>;
50
51 fn folds_case(&self) -> bool {
69 false
70 }
71}
72
73#[derive(Clone, Copy, Debug, Default)]
75pub struct GreedyMatcher {
76 pub case_sensitive: bool,
78}
79
80#[derive(Clone, Copy, Debug, Default)]
82pub struct ExactMatcher {
83 pub case_sensitive: bool,
85}
86
87impl GreedyMatcher {
88 pub fn new(case_sensitive: bool) -> Self {
90 Self { case_sensitive }
91 }
92}
93
94impl ExactMatcher {
95 pub fn new(case_sensitive: bool) -> Self {
97 Self { case_sensitive }
98 }
99}
100
101#[derive(Clone, Debug)]
109pub struct NucleoMatcher {
110 matcher: Matcher,
111 pattern_buf: Vec<char>,
112 text_buf: Vec<char>,
113 folded: FoldedPattern,
114}
115
116impl NucleoMatcher {
117 pub fn new(case_sensitive: bool) -> Self {
119 let mut config = NucleoConfig::DEFAULT;
120 config.ignore_case = !case_sensitive;
121 Self {
122 matcher: Matcher::new(config),
123 pattern_buf: Vec::new(),
124 text_buf: Vec::new(),
125 folded: FoldedPattern::default(),
126 }
127 }
128}
129
130#[derive(Clone, Debug, Default)]
137struct FoldedPattern {
138 source: String,
139 text: String,
140 needed: bool,
141}
142
143impl FoldedPattern {
144 #[inline]
147 fn pattern<'a>(&'a mut self, pattern: &'a str) -> &'a str {
148 if self.source != pattern {
149 self.source.clear();
150 self.source.push_str(pattern);
151 self.needed = pattern.chars().any(chars::is_upper_case);
152 if self.needed {
153 self.text.clear();
154 self.text.extend(pattern.chars().map(chars::to_lower_case));
155 }
156 }
157
158 if self.needed {
159 &self.text
160 } else {
161 pattern
162 }
163 }
164}
165
166impl Default for NucleoMatcher {
167 fn default() -> Self {
168 Self::new(false)
169 }
170}
171
172impl MatcherBackend for GreedyMatcher {
173 fn score(&mut self, pattern: &str, text: &str) -> Option<i64> {
174 score_text(pattern, text, self.case_sensitive)
175 }
176
177 fn folds_case(&self) -> bool {
181 !self.case_sensitive
182 }
183}
184
185impl MatcherBackend for ExactMatcher {
186 fn score(&mut self, pattern: &str, text: &str) -> Option<i64> {
187 score_exact_text(pattern, text, self.case_sensitive)
188 }
189
190 fn folds_case(&self) -> bool {
194 !self.case_sensitive
195 }
196}
197
198impl MatcherBackend for NucleoMatcher {
199 fn score(&mut self, pattern: &str, text: &str) -> Option<i64> {
214 let Self {
215 matcher,
216 pattern_buf,
217 text_buf,
218 folded,
219 } = self;
220
221 let pattern = if matcher.config.ignore_case {
222 folded.pattern(pattern)
223 } else {
224 pattern
225 };
226
227 let pattern = Utf32Str::new(pattern, pattern_buf);
228 let text = Utf32Str::new(text, text_buf);
229 matcher.fuzzy_match(text, pattern).map(i64::from)
230 }
231
232 fn folds_case(&self) -> bool {
242 false
243 }
244}
245
246#[derive(Clone, Debug, Eq, PartialEq)]
248pub struct MatchPositions {
249 pub char_indices: Vec<usize>,
251}
252
253impl MatchPositions {
254 pub fn is_empty(&self) -> bool {
256 self.char_indices.is_empty()
257 }
258}
259
260pub fn score_key(variant: &QueryVariant, key: &SearchKey, case_sensitive: bool) -> Option<i64> {
262 if !key_kind_allowed(variant, key.kind) {
263 return None;
264 }
265
266 score_text(&variant.text, &key.text, case_sensitive)
267 .map(|score| score + i64::from(key.weight + variant.weight))
268}
269
270pub fn score_text(pattern: &str, text: &str, case_sensitive: bool) -> Option<i64> {
276 if pattern.is_empty() {
277 return Some(0);
278 }
279
280 if pattern.is_ascii() && text.is_ascii() {
281 return if case_sensitive {
284 score_ascii_text::<true>(pattern, text)
285 } else {
286 score_ascii_text::<false>(pattern, text)
287 };
288 }
289
290 if case_sensitive {
291 return score_unicode_text::<true>(pattern, text);
292 }
293
294 score_unicode_text::<false>(pattern, text).or_else(|| {
295 retry_with_rewritten_multi_char_lowercase(pattern, text, score_rewritten_unicode_text)
296 })
297}
298
299fn fold_char<const CASE_SENSITIVE: bool>(ch: char) -> char {
313 if CASE_SENSITIVE {
314 ch
315 } else if ch.is_ascii() {
316 ch.to_ascii_lowercase()
317 } else {
318 let mut lower = ch.to_lowercase();
319 match (lower.len(), lower.next()) {
320 (1, Some(lower)) => lower,
321 _ => ch,
322 }
323 }
324}
325
326pub(crate) fn fold_case_char(ch: char) -> char {
328 fold_char::<false>(ch)
329}
330
331pub(crate) const MULTI_CHAR_LOWERCASE: char = 'İ';
339
340pub(crate) const MULTI_CHAR_LOWERCASE_EXPANSION: &str = "i\u{307}";
342
343const MULTI_CHAR_LOWERCASE_LEAD_BYTE: u8 = 0xC4;
350const _: () = assert!(
351 (MULTI_CHAR_LOWERCASE as u32) >= 0x80
352 && (MULTI_CHAR_LOWERCASE as u32) < 0x800
353 && MULTI_CHAR_LOWERCASE_LEAD_BYTE == 0xC0 | ((MULTI_CHAR_LOWERCASE as u32) >> 6) as u8,
354 "the lead byte must be the one a two-byte UTF-8 encoding of the character starts with"
355);
356
357fn retry_with_rewritten_multi_char_lowercase(
412 pattern: &str,
413 text: &str,
414 score: fn(&str, &str) -> Option<i64>,
415) -> Option<i64> {
416 let pattern_composed = contains_multi_char_lowercase(pattern);
417 let text_composed = contains_multi_char_lowercase(text);
418 if !pattern_composed && !text_composed {
419 return None;
420 }
421
422 let composed_pattern = compose_multi_char_lowercase(pattern);
423 let composed_text = compose_multi_char_lowercase(text);
424 if composed_pattern.is_some() || composed_text.is_some() {
425 let composed = score(
426 composed_pattern.as_deref().unwrap_or(pattern),
427 composed_text.as_deref().unwrap_or(text),
428 );
429 if composed.is_some() {
430 return composed;
431 }
432 }
433
434 score(
435 &expand_multi_char_lowercase(pattern, pattern_composed),
436 &expand_multi_char_lowercase(text, text_composed),
437 )
438}
439
440fn contains_multi_char_lowercase(text: &str) -> bool {
447 text.as_bytes().contains(&MULTI_CHAR_LOWERCASE_LEAD_BYTE) && text.contains(MULTI_CHAR_LOWERCASE)
448}
449
450fn compose_multi_char_lowercase(text: &str) -> Option<String> {
456 if !text.contains(MULTI_CHAR_LOWERCASE_EXPANSION) {
457 return None;
458 }
459
460 let mut composed = [0u8; 4];
461 Some(text.replace(
462 MULTI_CHAR_LOWERCASE_EXPANSION,
463 MULTI_CHAR_LOWERCASE.encode_utf8(&mut composed),
464 ))
465}
466
467fn expand_multi_char_lowercase(text: &str, contains: bool) -> Cow<'_, str> {
470 if contains {
471 Cow::Owned(text.replace(MULTI_CHAR_LOWERCASE, MULTI_CHAR_LOWERCASE_EXPANSION))
472 } else {
473 Cow::Borrowed(text)
474 }
475}
476
477const NAIVE_FOLDED_SCAN_BUDGET: usize = 4096;
484
485fn naive_folded_scan_affordable(text_len: usize, pattern_len: usize) -> bool {
487 (text_len.saturating_sub(pattern_len) + 1).saturating_mul(pattern_len)
488 <= NAIVE_FOLDED_SCAN_BUDGET
489}
490
491thread_local! {
492 static FOLD_SCRATCH: std::cell::Cell<String> = const { std::cell::Cell::new(String::new()) };
495}
496
497const FOLD_SCRATCH_RETAINED_BYTES: usize = 64 * 1024;
499
500fn find_folded_index(
507 text: impl Iterator<Item = char>,
508 pattern: impl Iterator<Item = char>,
509) -> Option<usize> {
510 let mut scratch = FOLD_SCRATCH.take();
511 scratch.clear();
512 scratch.extend(text);
513 let split = scratch.len();
514 scratch.extend(pattern);
515
516 let (text, pattern) = scratch.split_at(split);
517 let found = text
518 .find(pattern)
519 .map(|offset| text[..offset].chars().count());
520
521 if scratch.capacity() > FOLD_SCRATCH_RETAINED_BYTES {
522 scratch.shrink_to(FOLD_SCRATCH_RETAINED_BYTES);
523 }
524 FOLD_SCRATCH.set(scratch);
525 found
526}
527
528fn case_exact_bonus<const CASE_SENSITIVE: bool>(case_exact: bool) -> i64 {
534 if !CASE_SENSITIVE && case_exact {
535 BONUS_CASE_EXACT
536 } else {
537 0
538 }
539}
540
541fn fold_ascii<const CASE_SENSITIVE: bool>(byte: u8) -> u8 {
543 if CASE_SENSITIVE {
544 byte
545 } else {
546 byte.to_ascii_lowercase()
547 }
548}
549
550fn score_unicode_text<const CASE_SENSITIVE: bool>(pattern: &str, text: &str) -> Option<i64> {
551 score_unicode_text_with::<CASE_SENSITIVE, true>(pattern, text)
552}
553
554fn score_rewritten_unicode_text(pattern: &str, text: &str) -> Option<i64> {
562 score_unicode_text_with::<false, false>(pattern, text)
563}
564
565fn score_unicode_text_with<const CASE_SENSITIVE: bool, const CASE_EXACT_ALLOWED: bool>(
566 pattern: &str,
567 text: &str,
568) -> Option<i64> {
569 let pattern_chars: Vec<char> = pattern.chars().collect();
570 let text_chars: Vec<char> = text.chars().collect();
571 let compact_score = compact_char_match_score::<CASE_SENSITIVE, CASE_EXACT_ALLOWED>(
572 &pattern_chars,
573 &text_chars,
574 )?;
575
576 let exact_bonus = if CASE_SENSITIVE {
577 whole_text_bonus(pattern, text)
578 } else {
579 folded_whole_text_bonus(&pattern_chars, &text_chars)
580 };
581
582 Some(exact_bonus + compact_score)
583}
584
585fn whole_text_bonus(pattern: &str, text: &str) -> i64 {
587 if pattern == text {
588 10_000
589 } else if text.starts_with(pattern) {
590 8_000
591 } else if text.contains(pattern) {
592 6_000
593 } else {
594 0
595 }
596}
597
598fn folded_whole_text_bonus(pattern: &[char], text: &[char]) -> i64 {
600 if folded_chars_eq(pattern, text) {
601 10_000
602 } else if text.len() >= pattern.len() && folded_chars_eq(pattern, &text[..pattern.len()]) {
603 8_000
604 } else if folded_chars_contain(text, pattern) {
605 6_000
606 } else {
607 0
608 }
609}
610
611fn folded_chars_eq(left: &[char], right: &[char]) -> bool {
612 left.len() == right.len()
613 && left
614 .iter()
615 .zip(right)
616 .all(|(left, right)| fold_char::<false>(*left) == fold_char::<false>(*right))
617}
618
619fn folded_chars_contain(text: &[char], pattern: &[char]) -> bool {
620 let Some(last_start) = text.len().checked_sub(pattern.len()) else {
621 return false;
622 };
623
624 if !naive_folded_scan_affordable(text.len(), pattern.len()) {
625 return find_folded_index(
626 text.iter().copied().map(fold_char::<false>),
627 pattern.iter().copied().map(fold_char::<false>),
628 )
629 .is_some();
630 }
631
632 (0..=last_start).any(|start| folded_chars_eq(pattern, &text[start..start + pattern.len()]))
633}
634
635fn compact_char_match_score<const CASE_SENSITIVE: bool, const CASE_EXACT_ALLOWED: bool>(
636 pattern: &[char],
637 text: &[char],
638) -> Option<i64> {
639 if pattern.is_empty() {
640 return Some(0);
641 }
642 if pattern.len() > text.len() {
643 return None;
644 }
645
646 let mut pattern_index = 0usize;
647 let mut wanted = fold_char::<CASE_SENSITIVE>(pattern[0]);
648 let mut end = None;
649 for (text_index, &text_ch) in text.iter().enumerate() {
650 if fold_char::<CASE_SENSITIVE>(text_ch) == wanted {
651 pattern_index += 1;
652 if pattern_index == pattern.len() {
653 end = Some(text_index);
654 break;
655 }
656 wanted = fold_char::<CASE_SENSITIVE>(pattern[pattern_index]);
657 }
658 }
659
660 let mut text_index = end?;
661 let mut score = 1000;
662 let mut right_match: Option<usize> = None;
663 let mut first = 0usize;
664 let mut case_exact = true;
665 for pattern_index in (0..pattern.len()).rev() {
666 let wanted = fold_char::<CASE_SENSITIVE>(pattern[pattern_index]);
667 while fold_char::<CASE_SENSITIVE>(text[text_index]) != wanted {
668 if text_index == 0 {
669 return None;
670 }
671 text_index -= 1;
672 }
673 let position = text_index;
674 first = position;
675 if !CASE_SENSITIVE && text[position] != pattern[pattern_index] {
676 case_exact = false;
677 }
678
679 score += SCORE_MATCH;
680 let bonus = char_bonus_at(text, position);
681 if pattern_index == 0 {
682 score += bonus * BONUS_FIRST_CHAR_MULTIPLIER;
683 } else {
684 score += bonus;
685 }
686
687 if let Some(right_match) = right_match {
688 if right_match == position + 1 {
689 score += BONUS_CONSECUTIVE;
690 } else {
691 let gap = right_match.saturating_sub(position + 1) as i64;
692 score += SCORE_GAP_START + SCORE_GAP_EXTENSION * gap.saturating_sub(1);
693 }
694 }
695 right_match = Some(position);
696
697 if pattern_index > 0 {
698 if text_index == 0 {
699 return None;
700 }
701 text_index -= 1;
702 }
703 }
704
705 Some(
706 score + case_exact_bonus::<CASE_SENSITIVE>(CASE_EXACT_ALLOWED && case_exact)
707 - first as i64 * START_POSITION_PENALTY
708 - text.len() as i64 / TEXT_LENGTH_PENALTY_DIVISOR,
709 )
710}
711
712fn char_bonus_at(text: &[char], position: usize) -> i64 {
713 if position == 0 {
714 return BONUS_BOUNDARY_WHITE;
715 }
716
717 let previous = text[position - 1];
718 let current = text[position];
719 if previous.is_whitespace() {
720 BONUS_BOUNDARY_WHITE
721 } else if is_path_or_field_delimiter(previous) {
722 BONUS_BOUNDARY_DELIMITER
723 } else if !previous.is_alphanumeric() {
724 BONUS_BOUNDARY
725 } else if previous.is_lowercase() && current.is_uppercase()
726 || !previous.is_numeric() && current.is_numeric()
727 {
728 BONUS_CAMEL_OR_NUMBER
729 } else {
730 0
731 }
732}
733
734pub fn match_positions(pattern: &str, text: &str, case_sensitive: bool) -> Option<MatchPositions> {
736 if pattern.is_empty() {
737 return Some(MatchPositions {
738 char_indices: Vec::new(),
739 });
740 }
741
742 let pattern = comparable_chars(pattern, case_sensitive);
743 let text_comparable = comparable_indexed_chars(text, case_sensitive);
744 let text_chars: Vec<char> = text.chars().collect();
745 contiguous_text_positions(&pattern, &text_comparable)
746 .or_else(|| best_subsequence_positions(&pattern, &text_comparable, &text_chars))
747 .map(|char_indices| MatchPositions { char_indices })
748}
749
750fn comparable_chars(text: &str, case_sensitive: bool) -> Vec<char> {
751 comparable_indexed_chars(text, case_sensitive)
752 .into_iter()
753 .map(|(_, ch)| ch)
754 .collect()
755}
756
757fn comparable_indexed_chars(text: &str, case_sensitive: bool) -> Vec<(usize, char)> {
758 let mut out = Vec::new();
759 for (char_index, ch) in text.chars().enumerate() {
760 for normalized in std::iter::once(ch).nfkc() {
761 if case_sensitive {
762 out.push((char_index, comparable_char(normalized)));
763 } else {
764 out.extend(
765 normalized
766 .to_lowercase()
767 .map(|lower| (char_index, comparable_char(lower))),
768 );
769 }
770 }
771 }
772 out
773}
774
775fn comparable_char(ch: char) -> char {
776 let folded = crate::normalize::fold_width_compatible_char(ch);
777 if folded != ch {
778 folded
779 } else if ('ァ'..='ヶ').contains(&ch) {
780 char::from_u32(ch as u32 - 0x60).unwrap_or(ch)
781 } else {
782 ch
783 }
784}
785
786#[derive(Clone, Debug, Eq, PartialEq)]
787struct PositionCandidate {
788 score: i64,
789 positions: Vec<usize>,
790}
791
792fn best_subsequence_positions(
793 pattern: &[char],
794 text_comparable: &[(usize, char)],
795 text_chars: &[char],
796) -> Option<Vec<usize>> {
797 if pattern.len() > text_comparable.len() {
798 return None;
799 }
800
801 let mut states = Vec::new();
802 for &(text_index, text_ch) in text_comparable {
803 if pattern.first() == Some(&text_ch) {
804 states.push(Some(PositionCandidate {
805 score: match_position_score(text_chars, text_index) - text_index as i64 * 2,
806 positions: vec![text_index],
807 }));
808 } else {
809 states.push(None);
810 }
811 }
812
813 for &pattern_ch in &pattern[1..] {
814 let mut next_states = vec![None; text_comparable.len()];
815 for (text_offset, &(text_index, text_ch)) in text_comparable.iter().enumerate() {
816 if text_ch != pattern_ch {
817 continue;
818 }
819
820 let mut best = None;
821 for previous in states[..text_offset].iter().flatten() {
822 let Some(&previous_index) = previous.positions.last() else {
823 continue;
824 };
825 if previous_index >= text_index {
826 continue;
827 }
828
829 let mut positions = previous.positions.clone();
830 positions.push(text_index);
831 let gap = text_index.saturating_sub(previous_index + 1) as i64;
832 let consecutive_bonus = if text_index == previous_index + 1 {
833 160
834 } else {
835 0
836 };
837 let score = previous.score
838 + match_position_score(text_chars, text_index)
839 + consecutive_bonus
840 - gap * 4;
841 let candidate = PositionCandidate { score, positions };
842 if best
843 .as_ref()
844 .is_none_or(|current| better_position_candidate(&candidate, current))
845 {
846 best = Some(candidate);
847 }
848 }
849
850 next_states[text_offset] = best;
851 }
852
853 states = next_states;
854 }
855
856 states
857 .into_iter()
858 .flatten()
859 .max_by(compare_position_candidate)
860 .map(|candidate| candidate.positions)
861}
862
863fn match_position_score(text_chars: &[char], position: usize) -> i64 {
864 let boundary_bonus = if is_boundary(text_chars, position) {
865 90
866 } else {
867 0
868 };
869 100 + boundary_bonus
870}
871
872fn better_position_candidate(left: &PositionCandidate, right: &PositionCandidate) -> bool {
873 compare_position_candidate(left, right).is_gt()
874}
875
876fn compare_position_candidate(
877 left: &PositionCandidate,
878 right: &PositionCandidate,
879) -> std::cmp::Ordering {
880 left.score
881 .cmp(&right.score)
882 .then_with(|| span_len(right).cmp(&span_len(left)))
883 .then_with(|| right.positions.cmp(&left.positions))
884}
885
886fn span_len(candidate: &PositionCandidate) -> usize {
887 match (candidate.positions.first(), candidate.positions.last()) {
888 (Some(first), Some(last)) => last - first + 1,
889 _ => 0,
890 }
891}
892
893fn contiguous_text_positions(
894 pattern: &[char],
895 text_comparable: &[(usize, char)],
896) -> Option<Vec<usize>> {
897 if pattern.len() > text_comparable.len() {
898 return None;
899 }
900
901 text_comparable
902 .windows(pattern.len())
903 .find(|window| window.iter().map(|(_, ch)| ch).eq(pattern.iter()))
904 .map(|window| window.iter().map(|(index, _)| *index).collect())
905}
906
907fn score_ascii_text<const CASE_SENSITIVE: bool>(pattern: &str, text: &str) -> Option<i64> {
908 let pattern_bytes = pattern.as_bytes();
909 let text_bytes = text.as_bytes();
910
911 let compact_score = compact_ascii_match_score::<CASE_SENSITIVE>(pattern_bytes, text_bytes)?;
912
913 let exact_bonus = if CASE_SENSITIVE {
914 whole_text_bonus(pattern, text)
915 } else {
916 folded_ascii_whole_text_bonus(pattern_bytes, text_bytes)
917 };
918
919 Some(exact_bonus + compact_score)
920}
921
922fn folded_ascii_whole_text_bonus(pattern: &[u8], text: &[u8]) -> i64 {
924 if text.eq_ignore_ascii_case(pattern) {
925 10_000
926 } else if text.len() >= pattern.len() && text[..pattern.len()].eq_ignore_ascii_case(pattern) {
927 8_000
928 } else if find_ascii_ignore_case(text, pattern).is_some() {
929 6_000
930 } else {
931 0
932 }
933}
934
935fn find_ascii_ignore_case(text: &[u8], pattern: &[u8]) -> Option<usize> {
939 debug_assert!(text.is_ascii() && pattern.is_ascii());
940 let Some((&first, rest)) = pattern.split_first() else {
941 return Some(0);
942 };
943 let first = first.to_ascii_lowercase();
944 let last_start = text.len().checked_sub(pattern.len())?;
945
946 if rest.is_empty() {
947 return text
948 .iter()
949 .position(|byte| byte.to_ascii_lowercase() == first);
950 }
951
952 if !naive_folded_scan_affordable(text.len(), pattern.len()) {
953 return find_folded_index(
954 text.iter()
955 .map(|byte| char::from(byte.to_ascii_lowercase())),
956 pattern
957 .iter()
958 .map(|byte| char::from(byte.to_ascii_lowercase())),
959 );
960 }
961
962 (0..=last_start).find(|&start| {
963 text[start].to_ascii_lowercase() == first
964 && text[start + 1..start + pattern.len()].eq_ignore_ascii_case(rest)
965 })
966}
967
968fn compact_ascii_match_score<const CASE_SENSITIVE: bool>(
969 pattern: &[u8],
970 text: &[u8],
971) -> Option<i64> {
972 if pattern.is_empty() {
973 return Some(0);
974 }
975 if pattern.len() > text.len() {
976 return None;
977 }
978
979 let mut pattern_index = 0usize;
980 let mut wanted = fold_ascii::<CASE_SENSITIVE>(pattern[0]);
981 let mut end = None;
982 for (text_index, &text_byte) in text.iter().enumerate() {
983 if fold_ascii::<CASE_SENSITIVE>(text_byte) == wanted {
984 pattern_index += 1;
985 if pattern_index == pattern.len() {
986 end = Some(text_index);
987 break;
988 }
989 wanted = fold_ascii::<CASE_SENSITIVE>(pattern[pattern_index]);
990 }
991 }
992
993 let mut text_index = end?;
994 let mut score = 1000;
995 let mut right_match: Option<usize> = None;
996 let mut first = 0usize;
997 let mut case_exact = true;
998 for pattern_index in (0..pattern.len()).rev() {
999 let wanted = fold_ascii::<CASE_SENSITIVE>(pattern[pattern_index]);
1000 while fold_ascii::<CASE_SENSITIVE>(text[text_index]) != wanted {
1001 if text_index == 0 {
1002 return None;
1003 }
1004 text_index -= 1;
1005 }
1006 let position = text_index;
1007 first = position;
1008 if !CASE_SENSITIVE && text[position] != pattern[pattern_index] {
1009 case_exact = false;
1010 }
1011
1012 score += SCORE_MATCH;
1013 let bonus = ascii_bonus_at(text, position);
1014 if pattern_index == 0 {
1015 score += bonus * BONUS_FIRST_CHAR_MULTIPLIER;
1016 } else {
1017 score += bonus;
1018 }
1019
1020 if let Some(right_match) = right_match {
1021 if right_match == position + 1 {
1022 score += BONUS_CONSECUTIVE;
1023 } else {
1024 let gap = right_match.saturating_sub(position + 1) as i64;
1025 score += SCORE_GAP_START + SCORE_GAP_EXTENSION * gap.saturating_sub(1);
1026 }
1027 }
1028 right_match = Some(position);
1029
1030 if pattern_index > 0 {
1031 if text_index == 0 {
1032 return None;
1033 }
1034 text_index -= 1;
1035 }
1036 }
1037
1038 Some(
1039 score + case_exact_bonus::<CASE_SENSITIVE>(case_exact)
1040 - first as i64 * START_POSITION_PENALTY
1041 - text.len() as i64 / TEXT_LENGTH_PENALTY_DIVISOR,
1042 )
1043}
1044
1045fn ascii_bonus_at(text: &[u8], position: usize) -> i64 {
1046 if position == 0 {
1047 return BONUS_BOUNDARY_WHITE;
1048 }
1049
1050 let previous = text[position - 1];
1051 let current = text[position];
1052 if previous.is_ascii_whitespace() {
1053 BONUS_BOUNDARY_WHITE
1054 } else if matches!(previous, b'/' | b'\\' | b',' | b':' | b';' | b'|') {
1055 BONUS_BOUNDARY_DELIMITER
1056 } else if !previous.is_ascii_alphanumeric() {
1057 BONUS_BOUNDARY
1058 } else if previous.is_ascii_lowercase() && current.is_ascii_uppercase()
1059 || !previous.is_ascii_digit() && current.is_ascii_digit()
1060 {
1061 BONUS_CAMEL_OR_NUMBER
1062 } else {
1063 0
1064 }
1065}
1066
1067pub fn score_exact_text(pattern: &str, text: &str, case_sensitive: bool) -> Option<i64> {
1072 if let Some(score) = score_exact_folded_text(pattern, text, case_sensitive) {
1073 return Some(score);
1074 }
1075 if case_sensitive {
1076 return None;
1077 }
1078
1079 retry_with_rewritten_multi_char_lowercase(pattern, text, score_rewritten_exact_text)
1080}
1081
1082fn score_rewritten_exact_text(pattern: &str, text: &str) -> Option<i64> {
1086 score_exact_folded_text_with(pattern, text, false, false)
1087}
1088
1089fn score_exact_folded_text(pattern: &str, text: &str, case_sensitive: bool) -> Option<i64> {
1091 score_exact_folded_text_with(pattern, text, case_sensitive, true)
1092}
1093
1094fn score_exact_folded_text_with(
1095 pattern: &str,
1096 text: &str,
1097 case_sensitive: bool,
1098 case_exact_allowed: bool,
1099) -> Option<i64> {
1100 if pattern.is_empty() {
1101 return Some(0);
1102 }
1103
1104 let (start, case_bonus) = if case_sensitive {
1108 (text.find(pattern)?, 0)
1109 } else {
1110 let start = find_ignore_case(text, pattern)?;
1111 (
1112 start,
1113 case_exact_bonus::<false>(case_exact_allowed && text[start..].starts_with(pattern)),
1114 )
1115 };
1116 let whole_text = start == 0
1118 && if case_sensitive {
1119 pattern == text
1120 } else {
1121 eq_ignore_case(pattern, text)
1122 };
1123
1124 let exact_bonus = if whole_text {
1125 10_000
1126 } else if start == 0 {
1127 8_000
1128 } else {
1129 6_000
1130 };
1131 Some(1000 + exact_bonus + case_bonus - start as i64 * 5 - text.chars().count() as i64)
1132}
1133
1134fn find_ignore_case(text: &str, pattern: &str) -> Option<usize> {
1136 if text.is_ascii() && pattern.is_ascii() {
1137 return find_ascii_ignore_case(text.as_bytes(), pattern.as_bytes());
1138 }
1139
1140 if !naive_folded_scan_affordable(text.len(), pattern.len()) {
1141 let char_index = find_folded_index(
1142 text.chars().map(fold_char::<false>),
1143 pattern.chars().map(fold_char::<false>),
1144 )?;
1145 return text
1146 .char_indices()
1147 .nth(char_index)
1148 .map(|(offset, _)| offset);
1149 }
1150
1151 let first = fold_char::<false>(pattern.chars().next()?);
1152 text.char_indices()
1153 .filter(|&(_, ch)| fold_char::<false>(ch) == first)
1154 .map(|(index, _)| index)
1155 .find(|&index| starts_with_ignore_case(&text[index..], pattern))
1156}
1157
1158fn starts_with_ignore_case(text: &str, pattern: &str) -> bool {
1159 let mut text_chars = text.chars();
1160 pattern.chars().all(|expected| {
1161 text_chars.next().map(fold_char::<false>) == Some(fold_char::<false>(expected))
1162 })
1163}
1164
1165fn eq_ignore_case(left: &str, right: &str) -> bool {
1166 left.chars()
1167 .map(fold_char::<false>)
1168 .eq(right.chars().map(fold_char::<false>))
1169}
1170
1171fn is_boundary(text: &[char], position: usize) -> bool {
1172 position == 0 || matches!(text[position - 1], '/' | '\\' | '_' | '-' | ' ' | '.')
1173}
1174
1175fn is_path_or_field_delimiter(ch: char) -> bool {
1176 matches!(ch, '/' | '\\' | ',' | ':' | ';' | '|')
1177}
1178
1179#[cfg(test)]
1180mod tests;