diff --git a/src/score.rs b/src/score.rs index edf0302f..cef37f33 100644 --- a/src/score.rs +++ b/src/score.rs @@ -2,6 +2,7 @@ /// the query and the choice. /// /// It is modeled after https://github.com/felipesere/icepick.git +use std::cmp::max; // return (start, matched_len) pub fn compute_match_length(choice: &str, query: &str) -> Option<(usize, usize)> { @@ -39,6 +40,148 @@ pub fn compute_match_length(choice: &str, query: &str) -> Option<(usize, usize)> } } + +// The fuzzy search algorith is inspired by https://github.com/forrestthewoods/lib_fts +// +// # Why is new Fuzzy Match algorithm needed? +// 1. fzf use the query to compose regex for non-greedy match. When use for file/path match, it did +// not work out for the best(e.g. Camel case characters should weight more) +// 2. fzf will only show the `range` be matched, while I think it is not enought sometimes. with +// this fuzzy_match algorithm we are able to get all matched indics. +// +// # The complexity is O(n^2) +// An intuitive way of fuzzy match is to find all occurences of pattern characters in the target +// string. Then find out all the choice composites and evaluate the score for each. which will take +// O(n!) for each input string. Then with dynamic programing, we can reduce the time to O(n^3) with +// addition O(m*n) space; +// +// I think that O(n^3) may not be acceptable in the use case of fzf-rs. so I made some assumptions +// and reduce the complexity to O(n^2). Note that lib_fts' complexity is O(n). +// +// # The algorithm +// The scoring core is the same to lib_fts. The difference part is that I search more composites +// than lib_fts for a better result. +// +// \ A B a b C c +// \------------------------ +// a| 20 x -6 +// b| x 20+5 x .Y. +// c| +// +// For each matched key(b for example), we need to find all the matched A before and find the best +// matched A that will get us the max score for current b. For example, the one marked as '.Y.' in +// the previous example will search the row before(i.e. 'a') and calculate the max score for '.Y.'. +// +// This process will take O(n^3), because each row will cause O(n^2); +// +// So as shown below: +// 1 2 3 4 +// ...a.........a.........a....b...a....b.... +// a +// b b1 b2 +// +// if we already know that b1 gets max score when we choose a2, we assume that b2 will get max +// score only if 'a' is chosen within [a2..a4]. That means a1 is not searched any more. +// +// The worst case of course is also O(n^3) but normally that won't happan. Of course I wonder +// whether O(n^3) algorithm is acceptable or not. + +const BONUS_ADJACENCY: i32 = 5; +const BONUS_SEPARATOR: i32 = 10; +const BONUS_CAMEL: i32 = 10; +const PENALTY_LEADING: i32 = -3; // penalty applied for every letter before the first match +const PENALTY_MAX_LEADING: i32 = -9; // maxing penalty for leading letters +const PENALTY_UNMATCHED: i32 = -1; + +// judge how many scores the current index should get +fn fuzzy_score(string: &Vec, index: usize) -> i32 { + let mut score = 0; + if index == 0 { + return BONUS_SEPARATOR + BONUS_CAMEL; + } + + let prev = string[index-1]; + let cur = string[index]; + + // apply bonus for matches after a separator + if prev == ' ' || prev == '_' || prev == '-' || prev == '/' || prev == '\\' { + score += BONUS_SEPARATOR; + } + + // apply bonus for camelCases + if prev.is_lowercase() && cur.is_uppercase() { + score += BONUS_CAMEL; + } + + score +} + +pub fn fuzzy_match(choice: &str, pattern: &str) -> Option<(i32, Vec)>{ + if pattern.len() == 0 { + return Some((0, Vec::new())); + } + + let choice_chars: Vec = choice.chars().collect(); + let pattern_chars: Vec = pattern.to_lowercase().chars().collect(); + + let mut scores = vec![vec![]]; + let mut picked = vec![]; + + // initialize the first row of scores + for choice_idx in 0..choice.len() { + if pattern_chars[0] == choice_chars[choice_idx].to_lowercase().next().unwrap() { + let score = fuzzy_score(&choice_chars, choice_idx) + max(choice_idx as i32 * PENALTY_LEADING, PENALTY_MAX_LEADING); + scores[0].push((choice_idx, score, 0)); //(char_idx, score, vec_idx back_ref) + } + } + + for pattern_idx in 1..pattern.len() { + scores.push(vec![]); + + if scores[pattern_idx-1].len() <= 0 { + return None; + } + + let mut best_idx = 0; // points to previous line + for choice_idx in 1..choice.len() { + let mut best_score = -10000; + + if pattern_chars[pattern_idx] != choice_chars[choice_idx].to_lowercase().next().unwrap() { + continue; + } + + for (vec_idx, &(idx, prev_score, _)) in scores[pattern_idx-1][best_idx..].iter().enumerate() { + if idx >= choice_idx { + break; + } + + let mut char_score = fuzzy_score(&choice_chars, choice_idx); + let adjacent_num = choice_idx - idx - 1; + char_score += if adjacent_num == 0 {BONUS_ADJACENCY} else {PENALTY_UNMATCHED * adjacent_num as i32}; + char_score += prev_score; + + if char_score > best_score { + best_score = char_score; + best_idx = vec_idx; + } + } + + scores[pattern_idx].push((choice_idx, best_score, best_idx)); + } + } + + let (mut next_col, &(_, score, _)) = scores[pattern.len()-1].iter().enumerate().max_by_key(|&(_, &x)| x.1).unwrap(); + let mut pattern_idx = pattern.len() as i32 - 1; + while pattern_idx >= 0 { + let (idx, _, next) = scores[pattern_idx as usize][next_col]; + next_col = next; + picked.push(idx); + pattern_idx -= 1; + } + picked.reverse(); + Some((score, picked)) +} + #[cfg(test)] mod test { use super::*; @@ -61,4 +204,11 @@ mod test { let query_4 = "PP"; assert_eq!(compute_match_length(&choice_4, &query_4), None); } + + #[test] + fn teset_fuzzy_match() { + let choice_4 = "AaBbcc"; + let query_4 = "abc"; + assert_eq!(fuzzy_match(&choice_4, &query_4), Some((28, vec![0,2,4]))); + } }