const SCORE_MATCH: i32 = 16;
const SCORE_GAP_START: i32 = -3;
const SCORE_GAP_EXTEND: i32 = -1;
const BONUS_BOUNDARY: i32 = SCORE_MATCH / 2; const BONUS_CAMEL: i32 = BONUS_BOUNDARY - 1; const BONUS_CONSECUTIVE: i32 = -(SCORE_GAP_START + SCORE_GAP_EXTEND); const BONUS_FIRST_CHAR_MULT: i32 = 2;
const SCORE_NEG_INF: i32 = i32::MIN / 4;
#[derive(Copy, Clone, PartialEq)]
enum CharKind {
NonWord,
Lower,
Upper,
Number,
}
fn char_kind(c: char) -> CharKind {
if c.is_ascii_lowercase() {
CharKind::Lower
} else if c.is_ascii_uppercase() {
CharKind::Upper
} else if c.is_ascii_digit() {
CharKind::Number
} else if c.is_alphanumeric() {
CharKind::Lower
} else {
CharKind::NonWord
}
}
fn boundary_bonus(prev: CharKind, curr: CharKind) -> i32 {
use CharKind::*;
match (prev, curr) {
(NonWord, c) if c != NonWord => BONUS_BOUNDARY,
(Lower, Upper) => BONUS_CAMEL,
(Lower | Upper, Number) => BONUS_CAMEL,
_ => 0,
}
}
pub(super) fn ascii_find_lower(hay: &str, needle_lower: &str) -> Option<usize> {
let hay = hay.as_bytes();
let ndl = needle_lower.as_bytes();
if ndl.is_empty() {
return Some(0);
}
if hay.len() < ndl.len() {
return None;
}
'outer: for start in 0..=hay.len() - ndl.len() {
for (k, &n) in ndl.iter().enumerate() {
if hay[start + k].to_ascii_lowercase() != n {
continue 'outer;
}
}
return Some(start);
}
None
}
pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(i32, Vec<usize>)> {
if needle.is_empty() {
return Some((0, Vec::new()));
}
let hay: Vec<char> = haystack.chars().collect();
let ndl: Vec<char> = needle.chars().collect();
let n = ndl.len();
let m = hay.len();
if n > m {
return None;
}
let mut bonus = vec![0i32; m];
let mut prev_kind = CharKind::NonWord;
for (j, &c) in hay.iter().enumerate() {
let k = char_kind(c);
bonus[j] = boundary_bonus(prev_kind, k);
prev_kind = k;
}
let cell = |i: usize, j: usize| i * m + j;
let mut mscore = vec![SCORE_NEG_INF; n * m];
let mut gscore = vec![SCORE_NEG_INF; n * m];
let mut mparent = vec![usize::MAX; n * m];
let mut gmatch = vec![usize::MAX; n * m];
for i in 0..n {
for j in i..m {
let nc = ndl[i];
let hc = hay[j];
let is_match = nc.eq_ignore_ascii_case(&hc);
if is_match {
let case_bonus = if nc == hc { 1 } else { 0 };
let ms = if i == 0 {
SCORE_MATCH + bonus[j] * BONUS_FIRST_CHAR_MULT + case_bonus
} else if j == 0 {
SCORE_NEG_INF
} else {
let from_m = mscore[cell(i - 1, j - 1)];
let from_g = gscore[cell(i - 1, j - 1)];
let consec_bonus = BONUS_CONSECUTIVE.max(bonus[j]);
let via_m = from_m.saturating_add(SCORE_MATCH + consec_bonus + case_bonus);
let via_g = from_g.saturating_add(SCORE_MATCH + bonus[j] + case_bonus);
if from_m == SCORE_NEG_INF && from_g == SCORE_NEG_INF {
SCORE_NEG_INF
} else if via_m >= via_g {
mparent[cell(i, j)] = j - 1;
via_m
} else {
mparent[cell(i, j)] = gmatch[cell(i - 1, j - 1)];
via_g
}
};
mscore[cell(i, j)] = ms;
}
if j > 0 {
let from_m = mscore[cell(i, j - 1)];
let from_g = gscore[cell(i, j - 1)];
let start = from_m.saturating_add(SCORE_GAP_START);
let extend = from_g.saturating_add(SCORE_GAP_EXTEND);
if from_m == SCORE_NEG_INF && from_g == SCORE_NEG_INF {
} else if extend >= start {
gscore[cell(i, j)] = extend;
gmatch[cell(i, j)] = gmatch[cell(i, j - 1)];
} else {
gscore[cell(i, j)] = start;
gmatch[cell(i, j)] = j - 1;
}
}
}
}
let mut best_score = SCORE_NEG_INF;
let mut best_j = usize::MAX;
for j in (n - 1)..m {
let s = mscore[cell(n - 1, j)];
if s > best_score {
best_score = s;
best_j = j;
}
}
if best_j == usize::MAX || best_score == SCORE_NEG_INF {
return None;
}
let mut positions = Vec::with_capacity(n);
let mut i = n - 1;
let mut j = best_j;
positions.push(j);
while i > 0 {
let pj = mparent[cell(i, j)];
if pj == usize::MAX {
return None;
}
j = pj;
i -= 1;
positions.push(j);
}
positions.reverse();
Some((best_score, positions))
}
#[cfg(test)]
mod tests {
use super::*;
fn matched(haystack: &str, needle: &str) -> Option<String> {
let (_score, positions) = fuzzy_match(haystack, needle)?;
let hay: Vec<char> = haystack.chars().collect();
Some(positions.iter().map(|&i| hay[i]).collect())
}
#[test]
fn skips_dot_separator() {
let (_score, positions) = fuzzy_match("xx.go", "xxgo").expect("should match");
assert_eq!(positions, vec![0, 1, 3, 4]);
}
#[test]
fn skips_underscore_and_slash() {
assert_eq!(matched("foo_bar", "foobar").as_deref(), Some("foobar"));
assert_eq!(matched("src/foo.rs", "foors").as_deref(), Some("foors"));
assert_eq!(matched("foo bar", "foobar").as_deref(), Some("foobar"));
}
#[test]
fn case_insensitive() {
let (_s, pos) = fuzzy_match("README.md", "readme").unwrap();
assert_eq!(pos, vec![0, 1, 2, 3, 4, 5]);
}
#[test]
fn camel_case_boundary_preferred() {
let (camel, _) = fuzzy_match("fooBar", "foob").unwrap();
let (flat, _) = fuzzy_match("flooba", "foob").unwrap();
assert!(camel > flat, "camelCase {camel} should outrank flat {flat}");
}
#[test]
fn word_boundary_outranks_mid_word() {
let (boundary, _) = fuzzy_match("src/foo", "foo").unwrap();
let (mid, _) = fuzzy_match("srcafoo", "foo").unwrap();
assert!(
boundary > mid,
"boundary {boundary} should outrank mid-word {mid}"
);
}
#[test]
fn subsequence_with_long_gap() {
let (_score, positions) = fuzzy_match("alphabet_soup", "asp").unwrap();
assert_eq!(positions.len(), 3);
assert!(positions.windows(2).all(|w| w[0] < w[1]));
}
#[test]
fn rejects_when_letters_missing() {
assert!(fuzzy_match("xx.go", "xxrs").is_none());
assert!(fuzzy_match("abc", "abcd").is_none());
assert!(fuzzy_match("src/foo", "srcz").is_none());
}
}