fuzzy_nucleo.rs

  1mod matcher;
  2mod paths;
  3mod strings;
  4
  5use fuzzy::CharBag;
  6use nucleo::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
  7
  8pub use paths::{
  9    PathMatch, PathMatchCandidate, PathMatchCandidateSet, match_fixed_path_set, match_path_sets,
 10};
 11pub use strings::{StringMatch, StringMatchCandidate, match_strings, match_strings_async};
 12
 13pub(crate) struct Cancelled;
 14
 15#[derive(Copy, Clone, Debug, PartialEq, Eq)]
 16pub enum Case {
 17    Smart,
 18    Ignore,
 19}
 20
 21impl Case {
 22    pub fn smart_if_uppercase_in(query: &str) -> Self {
 23        if query.chars().any(|c| c.is_uppercase()) {
 24            Self::Smart
 25        } else {
 26            Self::Ignore
 27        }
 28    }
 29
 30    pub fn is_smart(self) -> bool {
 31        matches!(self, Self::Smart)
 32    }
 33}
 34
 35#[derive(Copy, Clone, Debug, PartialEq, Eq)]
 36pub enum LengthPenalty {
 37    On,
 38    Off,
 39}
 40
 41impl LengthPenalty {
 42    pub fn from_bool(on: bool) -> Self {
 43        if on { Self::On } else { Self::Off }
 44    }
 45
 46    pub fn is_on(self) -> bool {
 47        matches!(self, Self::On)
 48    }
 49}
 50
 51// Matching is always case-insensitive at the nucleo level — using
 52// `CaseMatching::Smart` there would *reject* candidates whose capitalization
 53// doesn't match the query, breaking pickers like the command palette
 54// (`"Editor: Backspace"` against the action named `"editor: backspace"`).
 55// `Case::Smart` is honored as a *scoring hint* instead: when the query
 56// contains uppercase, candidates whose matched characters disagree in case
 57// are downranked by a per-mismatch penalty rather than dropped.
 58pub(crate) struct Query {
 59    pub(crate) pattern: Pattern,
 60    /// Non-whitespace query chars in input order, populated only when a smart-case
 61    /// penalty will actually be charged. Aligns 1:1 with the indices appended by
 62    /// `Pattern::indices` (atom-order, needle-order within each atom).
 63    pub(crate) query_chars: Option<Vec<char>>,
 64    pub(crate) char_bag: CharBag,
 65}
 66
 67impl Query {
 68    pub(crate) fn build(query: &str, case: Case) -> Option<Self> {
 69        if query.chars().all(char::is_whitespace) {
 70            return None;
 71        }
 72        let normalized = query.split_whitespace().collect::<Vec<_>>().join(" ");
 73        let pattern = Pattern::new(
 74            &normalized,
 75            CaseMatching::Ignore,
 76            Normalization::Smart,
 77            AtomKind::Fuzzy,
 78        );
 79        let wants_case_penalty = case.is_smart() && query.chars().any(|c| c.is_uppercase());
 80        let query_chars =
 81            wants_case_penalty.then(|| query.chars().filter(|c| !c.is_whitespace()).collect());
 82        Some(Query {
 83            pattern,
 84            query_chars,
 85            char_bag: CharBag::from(query),
 86        })
 87    }
 88}
 89
 90#[inline]
 91pub(crate) fn count_case_mismatches(
 92    query_chars: Option<&[char]>,
 93    matched_chars: &[u32],
 94    candidate: &str,
 95    candidate_chars: &mut Vec<char>,
 96) -> u32 {
 97    let Some(query_chars) = query_chars else {
 98        return 0;
 99    };
100    if query_chars.len() != matched_chars.len() {
101        return 0;
102    }
103    candidate_chars.clear();
104    candidate_chars.extend(candidate.chars());
105    let mut mismatches: u32 = 0;
106    for (&query_char, &pos) in query_chars.iter().zip(matched_chars) {
107        if let Some(&candidate_char) = candidate_chars.get(pos as usize)
108            && candidate_char != query_char
109            && candidate_char.eq_ignore_ascii_case(&query_char)
110        {
111            mismatches += 1;
112        }
113    }
114    mismatches
115}
116
117const SMART_CASE_PENALTY_PER_MISMATCH: f64 = 0.9;
118
119#[inline]
120pub(crate) fn case_penalty(mismatches: u32) -> f64 {
121    if mismatches == 0 {
122        1.0
123    } else {
124        SMART_CASE_PENALTY_PER_MISMATCH.powi(mismatches as i32)
125    }
126}
127
128/// Reconstruct byte-offset match positions from a list of matched char offsets
129/// that is already sorted ascending and deduplicated.
130pub(crate) fn positions_from_sorted(s: &str, sorted_char_indices: &[u32]) -> Vec<usize> {
131    let mut iter = sorted_char_indices.iter().copied().peekable();
132    let mut out = Vec::with_capacity(sorted_char_indices.len());
133    for (char_offset, (byte_offset, _)) in s.char_indices().enumerate() {
134        if iter.peek().is_none() {
135            break;
136        }
137        if iter.next_if(|&m| m == char_offset as u32).is_some() {
138            out.push(byte_offset);
139        }
140    }
141    out
142}