#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match {
pub score: i64,
pub positions: Vec<usize>,
}
const MATCH: i64 = 16;
const BOUNDARY: i64 = 8;
const CAMEL: i64 = 7;
const CONSECUTIVE: i64 = 8;
const GAP_START: i64 = 3;
fn separator(c: char) -> bool {
c.is_whitespace() || matches!(c, '/' | '\\' | '_' | '-' | '.' | ':' | ',' | '(' | '[')
}
fn bonus(chars: &[char], index: usize) -> i64 {
match index.checked_sub(1).map(|previous| chars[previous]) {
None => BOUNDARY,
Some(previous) if separator(previous) => BOUNDARY,
Some(previous) if previous.is_lowercase() && chars[index].is_uppercase() => CAMEL,
_ => 0,
}
}
const BUDGET: usize = 1 << 20;
fn score_of(candidate: &[char], positions: &[usize]) -> i64 {
let mut score = 0;
for (i, &j) in positions.iter().enumerate() {
score += MATCH + bonus(candidate, j);
if i > 0 {
let previous = positions[i - 1];
if j == previous + 1 {
score += CONSECUTIVE;
} else {
score -= GAP_START + (j - previous - 2) as i64;
}
}
}
score
}
fn term(pattern: &[char], candidate: &[char], fold: bool) -> Option<Match> {
let (m, n) = (pattern.len(), candidate.len());
if m == 0 {
return Some(Match {
score: 0,
positions: Vec::new(),
});
}
if m > n {
return None;
}
let same = |p: char, c: char| {
if fold {
c.to_lowercase().eq(p.to_lowercase())
} else {
p == c
}
};
let mut greedy = Vec::with_capacity(m);
let mut next = pattern.iter();
let mut wanted = next.next();
for (j, &c) in candidate.iter().enumerate() {
match wanted {
Some(&p) if same(p, c) => {
greedy.push(j);
wanted = next.next();
}
Some(_) => {}
None => break,
}
}
if greedy.len() < m {
return None;
}
if m.saturating_mul(n) > BUDGET {
return Some(Match {
score: score_of(candidate, &greedy),
positions: greedy,
});
}
const NONE: i64 = i64::MIN / 4;
let mut score = vec![NONE; m * n];
let mut from = vec![usize::MAX; m * n];
for j in 0..n {
if same(pattern[0], candidate[j]) {
score[j] = MATCH + bonus(candidate, j);
}
}
for i in 1..m {
let mut best: (i64, usize) = (NONE, usize::MAX);
for j in 1..n {
if j >= 2 {
let k = j - 2;
let value = score[(i - 1) * n + k];
if value > NONE && value + k as i64 > best.0 {
best = (value + k as i64, k);
}
}
if !same(pattern[i], candidate[j]) {
continue;
}
let here = MATCH + bonus(candidate, j);
let adjacent = score[(i - 1) * n + j - 1];
let run = if adjacent > NONE {
adjacent + CONSECUTIVE
} else {
NONE
};
let gap = if best.0 > NONE {
best.0 - j as i64 - GAP_START + 2
} else {
NONE
};
let (value, previous) = if run >= gap {
(run, j - 1)
} else {
(gap, best.1)
};
if value > NONE {
score[i * n + j] = value + here;
from[i * n + j] = previous;
}
}
}
let last = m - 1;
let (end, best) = (0..n)
.map(|j| (j, score[last * n + j]))
.filter(|(_, value)| *value > NONE)
.max_by_key(|&(j, value)| (value, std::cmp::Reverse(j)))?;
let mut positions = vec![0; m];
let mut j = end;
for i in (0..m).rev() {
positions[i] = j;
if i > 0 {
j = from[i * n + j];
}
}
Some(Match {
score: best,
positions,
})
}
pub fn fuzzy(pattern: &str, candidate: &str) -> Option<Match> {
let candidate: Vec<char> = candidate.chars().collect();
let mut total = Match {
score: 0,
positions: Vec::new(),
};
for word in pattern.split_whitespace() {
let chars: Vec<char> = word.chars().collect();
let fold = !chars.iter().any(|c| c.is_uppercase());
let found = term(&chars, &candidate, fold)?;
total.score += found.score;
total.positions.extend(found.positions);
}
total.positions.sort_unstable();
total.positions.dedup();
Some(total)
}
pub fn rank<'a>(
pattern: &str,
candidates: impl IntoIterator<Item = &'a str>,
) -> Vec<(usize, Match)> {
let mut found: Vec<(usize, usize, Match)> = candidates
.into_iter()
.enumerate()
.filter_map(|(index, candidate)| {
fuzzy(pattern, candidate).map(|m| (index, candidate.chars().count(), m))
})
.collect();
if !pattern.trim().is_empty() {
found.sort_by(|a, b| {
b.2.score
.cmp(&a.2.score)
.then(a.1.cmp(&b.1))
.then(a.0.cmp(&b.0))
});
}
found.into_iter().map(|(index, _, m)| (index, m)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_characters_in_order() {
assert!(fuzzy("abc", "a_b_c").is_some());
assert!(fuzzy("acb", "abc").is_none());
assert!(fuzzy("", "anything").unwrap().positions.is_empty());
assert!(fuzzy("xyz", "xy").is_none());
}
#[test]
fn prefers_word_starts_and_runs() {
assert_eq!(fuzzy("fb", "fabric_bar").unwrap().positions, [0, 7]);
assert_eq!(fuzzy("bar", "b_a_r bar").unwrap().positions, [6, 7, 8]);
assert_eq!(fuzzy("gc", "getConfig").unwrap().positions, [0, 3]);
}
#[test]
fn smart_case() {
assert!(fuzzy("readme", "README.md").is_some());
assert!(fuzzy("Readme", "readme.md").is_none());
assert!(fuzzy("README", "README.md").is_some());
}
#[test]
fn every_term_must_match() {
let found = fuzzy("main rs", "src/main.rs").unwrap();
assert_eq!(found.positions, [4, 5, 6, 7, 9, 10]);
assert!(fuzzy("main py", "src/main.rs").is_none());
}
#[test]
fn huge_problems_are_bounded() {
let line = "ab".repeat(150_000);
let query = "b".repeat(2000);
let found = fuzzy(&query, &line).unwrap();
assert_eq!(found.positions.len(), 2000);
assert_eq!(found.positions[..2], [1, 3]);
assert!(fuzzy(&"c".repeat(2000), &line).is_none());
let mut almost = "b".repeat(2000);
almost.push('c');
assert!(fuzzy(&almost, &line).is_none());
}
#[test]
fn the_greedy_score_follows_the_same_rules() {
let candidate: Vec<char> = "foo_bar".chars().collect();
let found = fuzzy("fb", "foo_bar").unwrap();
assert_eq!(score_of(&candidate, &found.positions), found.score);
let candidate: Vec<char> = "b_a_r bar".chars().collect();
let found = fuzzy("bar", "b_a_r bar").unwrap();
assert_eq!(score_of(&candidate, &found.positions), found.score);
}
#[test]
fn ranks_the_intended_match_first() {
let files = [
"src/lib.rs",
"src/main.rs",
"docs/maintenance.md",
"Cargo.toml",
];
let ranked: Vec<usize> = rank("main", files).into_iter().map(|(i, _)| i).collect();
assert_eq!(ranked, [1, 2]);
let ranked: Vec<usize> = rank("", files).into_iter().map(|(i, _)| i).collect();
assert_eq!(ranked, [0, 1, 2, 3], "no pattern keeps the order");
let ranked: Vec<usize> = rank("rs", files).into_iter().map(|(i, _)| i).collect();
assert_eq!(ranked, [0, 1]);
}
}