use std::cell::RefCell;
use thread_local::ThreadLocal;
use crate::fuzzy_matcher::util::cheap_matches;
use crate::fuzzy_matcher::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
const SCORE_MIN: i64 = i64::MIN / 2;
const SCORE_MAX: i64 = i64::MAX / 2;
const SCORE_GAP_LEADING: i64 = -1; const SCORE_GAP_TRAILING: i64 = -1; const SCORE_GAP_INNER: i64 = -2;
const SCORE_MATCH_CONSECUTIVE: i64 = 200; const SCORE_MATCH_SLASH: i64 = 180; const SCORE_MATCH_WORD: i64 = 160; const SCORE_MATCH_CAPITAL: i64 = 140; const SCORE_MATCH_DOT: i64 = 120;
const SCORE_TYPO: i64 = -300;
const MATCH_MAX_LEN: usize = 1024;
const SCORE_TO_SKIM: i64 = 5;
#[inline]
fn bonus_index(ch: char) -> usize {
if ch.is_ascii_uppercase() {
2
} else {
usize::from(ch.is_ascii_lowercase() || ch.is_ascii_digit())
}
}
#[inline]
fn compute_bonus(prev_ch: char, ch: char) -> i64 {
match bonus_index(ch) {
0 => 0,
1 => match prev_ch {
'/' => SCORE_MATCH_SLASH,
'-' | '_' | ' ' => SCORE_MATCH_WORD,
'.' => SCORE_MATCH_DOT,
_ => 0,
},
2 => match prev_ch {
'/' => SCORE_MATCH_SLASH,
'-' | '_' | ' ' => SCORE_MATCH_WORD,
'.' => SCORE_MATCH_DOT,
c if c.is_ascii_lowercase() => SCORE_MATCH_CAPITAL,
_ => 0,
},
_ => unreachable!(),
}
}
fn precompute_bonus(haystack: &[char]) -> Vec<i64> {
let mut bonuses = Vec::with_capacity(haystack.len());
let mut prev = '/';
for &ch in haystack {
bonuses.push(compute_bonus(prev, ch));
prev = ch;
}
bonuses
}
#[inline]
fn is_match(
needle: &[char],
haystack: &[char],
lower_needle: &[char],
lower_haystack: &[char],
case_sensitive: bool,
i: usize,
j: usize,
) -> bool {
if case_sensitive {
needle[i] == haystack[j]
} else {
lower_needle[i] == lower_haystack[j]
}
}
#[allow(clippy::too_many_lines)]
fn fzy_score(
needle: &[char],
haystack: &[char],
case_sensitive: bool,
positions: Option<&mut Vec<IndexType>>,
) -> Option<i64> {
let n = needle.len();
let m = haystack.len();
if n == 0 || m > MATCH_MAX_LEN || n > m {
return None;
}
if n == m {
if let Some(pos) = positions {
pos.clear();
pos.extend(0..n);
}
return Some(SCORE_MAX);
}
let lower_needle: Vec<char> = needle.iter().map(char::to_ascii_lowercase).collect();
let lower_haystack: Vec<char> = haystack.iter().map(char::to_ascii_lowercase).collect();
let match_bonus = precompute_bonus(haystack);
if positions.is_some() {
let mut d_matrix: Vec<Vec<i64>> = vec![vec![SCORE_MIN; m]; n];
let mut m_matrix: Vec<Vec<i64>> = vec![vec![SCORE_MIN; m]; n];
{
let mut prev_score = SCORE_MIN;
let gap = if n == 1 { SCORE_GAP_TRAILING } else { SCORE_GAP_INNER };
for j in 0..m {
if is_match(needle, haystack, &lower_needle, &lower_haystack, case_sensitive, 0, j) {
let score = i64::try_from(j).unwrap_or(i64::MAX) * SCORE_GAP_LEADING + match_bonus[j];
d_matrix[0][j] = score;
prev_score = score;
m_matrix[0][j] = score;
} else {
prev_score += gap;
m_matrix[0][j] = prev_score;
}
}
}
for i in 1..n {
let mut prev_score = SCORE_MIN;
let gap_score = if i == n - 1 {
SCORE_GAP_TRAILING
} else {
SCORE_GAP_INNER
};
for j in 0..m {
if is_match(needle, haystack, &lower_needle, &lower_haystack, case_sensitive, i, j) {
let mut score = SCORE_MIN;
if j > 0 {
let prev_m = m_matrix[i - 1][j - 1];
let prev_d = d_matrix[i - 1][j - 1];
score = i64::max(prev_m + match_bonus[j], prev_d + SCORE_MATCH_CONSECUTIVE);
}
d_matrix[i][j] = score;
prev_score = i64::max(score, prev_score + gap_score);
m_matrix[i][j] = prev_score;
} else {
prev_score += gap_score;
m_matrix[i][j] = prev_score;
}
}
}
let final_score = m_matrix[n - 1][m - 1];
if let Some(pos) = positions {
pos.clear();
pos.resize(n, 0);
let mut match_required = false;
let mut j = m - 1;
for i in (0..n).rev() {
loop {
if d_matrix[i][j] != SCORE_MIN && (match_required || d_matrix[i][j] == m_matrix[i][j]) {
match_required =
i > 0 && j > 0 && m_matrix[i][j] == d_matrix[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE;
pos[i] = j;
j = j.saturating_sub(1);
break;
}
if j == 0 {
break;
}
j -= 1;
}
}
}
Some(final_score)
} else {
let mut d_row = vec![SCORE_MIN; m];
let mut m_row = vec![SCORE_MIN; m];
for i in 0..n {
let mut prev_score = SCORE_MIN;
let gap_score = if i == n - 1 {
SCORE_GAP_TRAILING
} else {
SCORE_GAP_INNER
};
let mut prev_d = SCORE_MIN;
let mut prev_m = SCORE_MIN;
for j in 0..m {
let old_d = d_row[j];
let old_m = m_row[j];
if is_match(needle, haystack, &lower_needle, &lower_haystack, case_sensitive, i, j) {
let score = if i == 0 {
i64::try_from(j).unwrap_or(i64::MAX) * SCORE_GAP_LEADING + match_bonus[j]
} else if j > 0 {
i64::max(prev_m + match_bonus[j], prev_d + SCORE_MATCH_CONSECUTIVE)
} else {
SCORE_MIN
};
d_row[j] = score;
prev_score = i64::max(score, prev_score + gap_score);
m_row[j] = prev_score;
} else {
d_row[j] = SCORE_MIN;
prev_score += gap_score;
m_row[j] = prev_score;
}
prev_d = old_d;
prev_m = old_m;
}
}
Some(m_row[m - 1])
}
}
#[inline]
fn can_match_with_typos(
choice: &[char],
pattern: &[char],
lower_pattern: &[char],
case_sensitive: bool,
max_typos: usize,
) -> bool {
let n = pattern.len();
let m = choice.len();
if n > m + max_typos {
return false;
}
let mut typos_used = 0;
let mut j = 0;
for i in 0..n {
let saved_j = j;
let mut found = false;
while j < m {
let matches = if case_sensitive {
pattern[i] == choice[j]
} else {
lower_pattern[i] == choice[j].to_ascii_lowercase()
};
if matches {
j += 1;
found = true;
break;
}
j += 1;
}
if !found {
j = saved_j;
typos_used += 1;
if typos_used > max_typos {
return false;
}
}
}
true
}
#[derive(Debug, Default)]
struct TypoDpBuffers {
d_buf: Vec<i64>,
m_buf: Vec<i64>,
d_full: Vec<i64>,
m_full: Vec<i64>,
}
#[allow(clippy::too_many_arguments)]
fn fzy_score_typos_rolling(
needle: &[char],
haystack: &[char],
lower_needle: &[char],
lower_haystack: &[char],
match_bonus: &[i64],
case_sensitive: bool,
max_typos: usize,
bufs: &mut TypoDpBuffers,
) -> Option<i64> {
let n = needle.len();
let m = haystack.len();
let t_max = max_typos;
let row_size = m;
let layer_size = 2 * row_size;
let total = (t_max + 1) * layer_size;
bufs.d_buf.clear();
bufs.d_buf.resize(total, SCORE_MIN);
bufs.m_buf.clear();
bufs.m_buf.resize(total, SCORE_MIN);
let d = &mut bufs.d_buf;
let m_arr = &mut bufs.m_buf;
let ri = |t: usize, parity: usize, j: usize| -> usize { t * layer_size + parity * row_size + j };
for i in 0..n {
let cur = i & 1;
let prev = 1 - cur;
let gap_score = if i == n - 1 {
SCORE_GAP_TRAILING
} else {
SCORE_GAP_INNER
};
for t in 0..=t_max {
let mut prev_score = SCORE_MIN;
for j in 0..m {
let matched = if case_sensitive {
needle[i] == haystack[j]
} else {
lower_needle[i] == lower_haystack[j]
};
let mut d_val = SCORE_MIN;
if matched {
if i == 0 {
d_val = i64::try_from(j).unwrap_or(i64::MAX) * SCORE_GAP_LEADING + match_bonus[j];
} else if j > 0 {
let pm = m_arr[ri(t, prev, j - 1)];
let pd = d[ri(t, prev, j - 1)];
if pm != SCORE_MIN {
d_val = i64::max(d_val, pm + match_bonus[j]);
}
if pd != SCORE_MIN {
d_val = i64::max(d_val, pd + SCORE_MATCH_CONSECUTIVE);
}
}
}
if !matched && t > 0 {
if i == 0 {
d_val = i64::max(
d_val,
i64::try_from(j).unwrap_or(i64::MAX) * SCORE_GAP_LEADING + SCORE_TYPO,
);
} else if j > 0 {
let pm = m_arr[ri(t - 1, prev, j - 1)];
if pm != SCORE_MIN {
d_val = i64::max(d_val, pm + SCORE_TYPO);
}
}
}
d[ri(t, cur, j)] = d_val;
if d_val == SCORE_MIN {
prev_score += gap_score;
} else {
prev_score = i64::max(d_val, prev_score + gap_score);
}
if t > 0 {
let del_from = if i == 0 {
i64::try_from(j).unwrap_or(i64::MAX) * SCORE_GAP_LEADING + SCORE_TYPO
} else {
let pv = m_arr[ri(t - 1, prev, j)];
if pv == SCORE_MIN { SCORE_MIN } else { pv + SCORE_TYPO }
};
if del_from != SCORE_MIN {
prev_score = i64::max(prev_score, del_from);
}
}
m_arr[ri(t, cur, j)] = prev_score;
}
}
}
let final_row = (n - 1) & 1;
let mut best = SCORE_MIN;
for t in 0..=t_max {
let s = m_arr[ri(t, final_row, m - 1)];
if s > best {
best = s;
}
}
if best == SCORE_MIN { None } else { Some(best) }
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::too_many_arguments)]
fn fzy_score_typos_full(
needle: &[char],
haystack: &[char],
lower_needle: &[char],
lower_haystack: &[char],
match_bonus: &[i64],
case_sensitive: bool,
max_typos: usize,
positions: &mut Vec<IndexType>,
bufs: &mut TypoDpBuffers,
) -> Option<i64> {
let n = needle.len();
let m = haystack.len();
let t_max = max_typos;
let layer_size = n * m;
let total = (t_max + 1) * layer_size;
bufs.d_full.clear();
bufs.d_full.resize(total, SCORE_MIN);
bufs.m_full.clear();
bufs.m_full.resize(total, SCORE_MIN);
let d_flat = &mut bufs.d_full;
let m_flat = &mut bufs.m_full;
let idx = |t: usize, i: usize, j: usize| -> usize { t * layer_size + i * m + j };
for t in 0..=t_max {
for i in 0..n {
let gap_score = if i == n - 1 {
SCORE_GAP_TRAILING
} else {
SCORE_GAP_INNER
};
let mut prev_score = SCORE_MIN;
for j in 0..m {
let matched = if case_sensitive {
needle[i] == haystack[j]
} else {
lower_needle[i] == lower_haystack[j]
};
let mut d_val = SCORE_MIN;
if matched {
if i == 0 {
d_val = i64::try_from(j).unwrap_or(i64::MAX) * SCORE_GAP_LEADING + match_bonus[j];
} else if j > 0 {
let pm = m_flat[idx(t, i - 1, j - 1)];
let pd = d_flat[idx(t, i - 1, j - 1)];
if pm != SCORE_MIN {
d_val = i64::max(d_val, pm + match_bonus[j]);
}
if pd != SCORE_MIN {
d_val = i64::max(d_val, pd + SCORE_MATCH_CONSECUTIVE);
}
}
}
if !matched && t > 0 {
if i == 0 {
d_val = i64::max(
d_val,
i64::try_from(j).unwrap_or(i64::MAX) * SCORE_GAP_LEADING + SCORE_TYPO,
);
} else if j > 0 {
let pm = m_flat[idx(t - 1, i - 1, j - 1)];
if pm != SCORE_MIN {
d_val = i64::max(d_val, pm + SCORE_TYPO);
}
}
}
d_flat[idx(t, i, j)] = d_val;
if d_val == SCORE_MIN {
prev_score += gap_score;
} else {
prev_score = i64::max(d_val, prev_score + gap_score);
}
if t > 0 {
let del_from = if i == 0 {
i64::try_from(j).unwrap_or(i64::MAX) * SCORE_GAP_LEADING + SCORE_TYPO
} else {
let pv = m_flat[idx(t - 1, i - 1, j)];
if pv == SCORE_MIN { SCORE_MIN } else { pv + SCORE_TYPO }
};
if del_from != SCORE_MIN {
prev_score = i64::max(prev_score, del_from);
}
}
m_flat[idx(t, i, j)] = prev_score;
}
}
}
let mut best_score = SCORE_MIN;
let mut best_t = 0;
for t in 0..=t_max {
let s = m_flat[idx(t, n - 1, m - 1)];
if s > best_score {
best_score = s;
best_t = t;
}
}
if best_score == SCORE_MIN {
return None;
}
positions.clear();
let mut cur_t = best_t;
let mut j = m - 1;
let mut rev_positions: Vec<Option<usize>> = Vec::with_capacity(n);
for i in (0..n).rev() {
loop {
let cur_m = m_flat[idx(cur_t, i, j)];
if cur_t > 0 {
let del_from = if i == 0 {
i64::try_from(j).unwrap_or(i64::MAX) * SCORE_GAP_LEADING + SCORE_TYPO
} else {
let pv = m_flat[idx(cur_t - 1, i - 1, j)];
if pv == SCORE_MIN { SCORE_MIN } else { pv + SCORE_TYPO }
};
if del_from != SCORE_MIN && cur_m == del_from {
rev_positions.push(None);
cur_t -= 1;
break;
}
}
let d_val = d_flat[idx(cur_t, i, j)];
if d_val != SCORE_MIN && d_val == cur_m {
rev_positions.push(Some(j));
j = j.saturating_sub(1);
break;
}
if j == 0 {
rev_positions.push(None);
break;
}
j -= 1;
}
}
rev_positions.reverse();
for p in rev_positions.iter().flatten() {
positions.push(*p);
}
Some(best_score)
}
#[inline]
fn internal_to_skim_score(score: i64) -> ScoreType {
if score == SCORE_MAX {
ScoreType::MAX / 2
} else if score == SCORE_MIN {
ScoreType::MIN / 2
} else {
score * SCORE_TO_SKIM
}
}
#[derive(Eq, PartialEq, Debug, Copy, Clone)]
enum CaseMatching {
Respect,
Ignore,
Smart,
}
#[derive(Debug)]
pub struct FzyMatcher {
case: CaseMatching,
use_cache: bool,
max_typos: Option<usize>,
c_cache: ThreadLocal<RefCell<Vec<char>>>,
p_cache: ThreadLocal<RefCell<Vec<char>>>,
lc_cache: ThreadLocal<RefCell<Vec<char>>>,
lp_cache: ThreadLocal<RefCell<Vec<char>>>,
typo_bufs: ThreadLocal<RefCell<TypoDpBuffers>>,
}
impl Default for FzyMatcher {
fn default() -> Self {
Self {
case: CaseMatching::Ignore,
use_cache: true,
max_typos: None,
c_cache: ThreadLocal::new(),
p_cache: ThreadLocal::new(),
lc_cache: ThreadLocal::new(),
lp_cache: ThreadLocal::new(),
typo_bufs: ThreadLocal::new(),
}
}
}
impl FzyMatcher {
#[must_use]
pub fn ignore_case(mut self) -> Self {
self.case = CaseMatching::Ignore;
self
}
#[must_use]
pub fn smart_case(mut self) -> Self {
self.case = CaseMatching::Smart;
self
}
#[must_use]
pub fn respect_case(mut self) -> Self {
self.case = CaseMatching::Respect;
self
}
#[must_use]
pub fn use_cache(mut self, use_cache: bool) -> Self {
self.use_cache = use_cache;
self
}
#[must_use]
pub fn max_typos(mut self, max_typos: Option<usize>) -> Self {
self.max_typos = max_typos;
self
}
fn contains_upper(string: &str) -> bool {
string.chars().any(char::is_uppercase)
}
fn is_case_sensitive(&self, pattern: &str) -> bool {
match self.case {
CaseMatching::Respect => true,
CaseMatching::Ignore => false,
CaseMatching::Smart => Self::contains_upper(pattern),
}
}
}
impl FuzzyMatcher for FzyMatcher {
fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, MatchIndices)> {
let case_sensitive = self.is_case_sensitive(pattern);
let mut choice_chars = self.c_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut();
let mut pattern_chars = self.p_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut();
choice_chars.clear();
choice_chars.extend(choice.chars());
pattern_chars.clear();
pattern_chars.extend(pattern.chars());
match self.max_typos {
None => {
cheap_matches(&choice_chars, &pattern_chars, case_sensitive)?;
let mut positions = Vec::with_capacity(pattern_chars.len());
let s = fzy_score(&pattern_chars, &choice_chars, case_sensitive, Some(&mut positions))?;
Some((internal_to_skim_score(s), MatchIndices::from(positions)))
}
Some(max_t) => {
if cheap_matches(&choice_chars, &pattern_chars, case_sensitive).is_some() {
let mut positions = Vec::with_capacity(pattern_chars.len());
if let Some(s) = fzy_score(&pattern_chars, &choice_chars, case_sensitive, Some(&mut positions)) {
return Some((internal_to_skim_score(s), MatchIndices::from(positions)));
}
}
if max_t == 0 {
return None;
}
let n = pattern_chars.len();
let m = choice_chars.len();
if n == 0 || m > MATCH_MAX_LEN || n > m + max_t {
return None;
}
let mut lower_pattern = self.lp_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut();
lower_pattern.clear();
lower_pattern.extend(pattern_chars.iter().map(char::to_ascii_lowercase));
if !can_match_with_typos(&choice_chars, &pattern_chars, &lower_pattern, case_sensitive, max_t) {
return None;
}
let mut lower_choice = self.lc_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut();
lower_choice.clear();
lower_choice.extend(choice_chars.iter().map(char::to_ascii_lowercase));
let match_bonus = precompute_bonus(&choice_chars);
let mut bufs = self
.typo_bufs
.get_or(|| RefCell::new(TypoDpBuffers::default()))
.borrow_mut();
let mut positions = Vec::with_capacity(n);
let s = fzy_score_typos_full(
&pattern_chars,
&choice_chars,
&lower_pattern,
&lower_choice,
&match_bonus,
case_sensitive,
max_t,
&mut positions,
&mut bufs,
)?;
drop(lower_choice);
drop(lower_pattern);
if !self.use_cache {
self.lc_cache.get().map(|cell| cell.replace(vec![]));
self.lp_cache.get().map(|cell| cell.replace(vec![]));
}
Some((internal_to_skim_score(s), MatchIndices::from(positions)))
}
}
}
fn fuzzy_match(&self, choice: &str, pattern: &str) -> Option<ScoreType> {
let case_sensitive = self.is_case_sensitive(pattern);
let mut choice_chars = self.c_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut();
let mut pattern_chars = self.p_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut();
choice_chars.clear();
choice_chars.extend(choice.chars());
pattern_chars.clear();
pattern_chars.extend(pattern.chars());
match self.max_typos {
None => {
cheap_matches(&choice_chars, &pattern_chars, case_sensitive)?;
let s = fzy_score(&pattern_chars, &choice_chars, case_sensitive, None)?;
Some(internal_to_skim_score(s))
}
Some(max_t) => {
if cheap_matches(&choice_chars, &pattern_chars, case_sensitive).is_some()
&& let Some(s) = fzy_score(&pattern_chars, &choice_chars, case_sensitive, None)
{
return Some(internal_to_skim_score(s));
}
if max_t == 0 {
return None;
}
let n = pattern_chars.len();
let m = choice_chars.len();
if n == 0 || m > MATCH_MAX_LEN || n > m + max_t {
return None;
}
let mut lower_pattern = self.lp_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut();
lower_pattern.clear();
lower_pattern.extend(pattern_chars.iter().map(char::to_ascii_lowercase));
if !can_match_with_typos(&choice_chars, &pattern_chars, &lower_pattern, case_sensitive, max_t) {
return None;
}
let mut lower_choice = self.lc_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut();
lower_choice.clear();
lower_choice.extend(choice_chars.iter().map(char::to_ascii_lowercase));
let match_bonus = precompute_bonus(&choice_chars);
let mut bufs = self
.typo_bufs
.get_or(|| RefCell::new(TypoDpBuffers::default()))
.borrow_mut();
let s = fzy_score_typos_rolling(
&pattern_chars,
&choice_chars,
&lower_pattern,
&lower_choice,
&match_bonus,
case_sensitive,
max_t,
&mut bufs,
)?;
drop(lower_choice);
drop(lower_pattern);
if !self.use_cache {
self.lc_cache.get().map(|cell| cell.replace(vec![]));
self.lp_cache.get().map(|cell| cell.replace(vec![]));
}
Some(internal_to_skim_score(s))
}
}
}
}
#[must_use]
pub fn fuzzy_indices(choice: &str, pattern: &str) -> Option<(ScoreType, MatchIndices)> {
FzyMatcher::default().ignore_case().fuzzy_indices(choice, pattern)
}
#[must_use]
pub fn fuzzy_match(choice: &str, pattern: &str) -> Option<ScoreType> {
FzyMatcher::default().ignore_case().fuzzy_match(choice, pattern)
}
#[cfg(test)]
#[path = "fzy_tests.rs"]
mod tests;