#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Class {
White = 0,
NonWord = 1,
Delimiter = 2,
Lower = 3,
Upper = 4,
Number = 5,
}
const SCORE_MATCH: i32 = 16;
const SCORE_GAP_START: i32 = -3;
const SCORE_GAP_EXTENSION: i32 = -1;
const BONUS_BOUNDARY: i32 = SCORE_MATCH / 2;
const BONUS_NON_WORD: i32 = SCORE_MATCH / 2;
const BONUS_CAMEL_123: i32 = BONUS_BOUNDARY - 1;
const BONUS_CONSECUTIVE: i32 = -(SCORE_GAP_START + SCORE_GAP_EXTENSION);
const BONUS_FIRST_CHAR_MULTIPLIER: i32 = 2;
const BONUS_BOUNDARY_WHITE: i32 = BONUS_BOUNDARY;
const BONUS_BOUNDARY_DELIMITER: i32 = BONUS_BOUNDARY + 1;
const BONUS_FILENAME: i32 = BONUS_BOUNDARY - 2;
const EXACT_BONUS: i32 = 1_000;
pub fn substring_rank(needle: &str, haystack: &str) -> Option<u8> {
let needle = needle.to_lowercase();
let hay = haystack.to_lowercase();
if needle.is_empty() {
Some(2)
} else if hay == needle {
Some(0)
} else if hay.starts_with(&needle) {
Some(1)
} else {
hay.contains(&needle).then_some(2)
}
}
fn class_of(c: char) -> Class {
if c.is_whitespace() {
Class::White
} else if matches!(c, '/' | '\\' | ',' | ':' | ';' | '|') {
Class::Delimiter
} else if c.is_ascii_digit() {
Class::Number
} else if c.is_uppercase() {
Class::Upper
} else if c.is_lowercase() || c.is_alphabetic() {
Class::Lower
} else {
Class::NonWord
}
}
fn bonus_for(prev: Class, curr: Class) -> i32 {
if curr > Class::NonWord {
match prev {
Class::White => return BONUS_BOUNDARY_WHITE,
Class::Delimiter => return BONUS_BOUNDARY_DELIMITER,
Class::NonWord => return BONUS_BOUNDARY,
_ => {}
}
}
if (prev == Class::Lower && curr == Class::Upper)
|| (prev != Class::Number && curr == Class::Number)
{
return BONUS_CAMEL_123;
}
match curr {
Class::NonWord | Class::Delimiter => BONUS_NON_WORD,
Class::White => BONUS_BOUNDARY_WHITE,
_ => 0,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Match {
pub score: i32,
pub positions: Vec<usize>,
}
fn score_from(
hay: &[char],
lower: &[char],
needle: &[char],
start: usize,
positions: &mut Vec<usize>,
) -> Option<i32> {
if lower[start] != needle[0] {
return None;
}
positions.clear();
let mut score = 0i32;
let mut consecutive = 0usize;
let mut first_bonus = 0i32;
if !hay[start..].iter().any(|c| *c == '/' || *c == '\\') {
score += BONUS_FILENAME;
}
let mut prev_class = if start == 0 {
Class::White
} else {
class_of(hay[start - 1])
};
let mut previous: Option<usize> = None;
for (n, needle_char) in needle.iter().enumerate() {
let from = previous.map(|p| p + 1).unwrap_or(start);
let pos = (from..lower.len()).find(|i| lower[*i] == *needle_char)?;
let class = class_of(hay[pos]);
let gap = previous.map(|p| pos - p - 1).unwrap_or(0);
let bonus = if gap > 0 {
prev_class = class_of(hay[pos - 1]);
let b = bonus_for(prev_class, class);
score += SCORE_GAP_START + (gap as i32 - 1) * SCORE_GAP_EXTENSION;
consecutive = 0;
first_bonus = 0;
b
} else {
let b = bonus_for(prev_class, class);
if consecutive == 0 {
first_bonus = b;
b
} else {
if b >= BONUS_BOUNDARY && b > first_bonus {
first_bonus = b;
}
b.max(first_bonus).max(BONUS_CONSECUTIVE)
}
};
score += SCORE_MATCH
+ if n == 0 {
bonus * BONUS_FIRST_CHAR_MULTIPLIER
} else {
bonus
};
consecutive += 1;
prev_class = class;
previous = Some(pos);
positions.push(pos);
}
Some(score)
}
pub fn best_match(needle: &str, haystack: &str) -> Option<Match> {
best_match_with(needle, haystack, |score, positions| Match {
score,
positions: positions.to_vec(),
})
}
#[derive(Default)]
struct Scratch {
hay: Vec<char>,
lower: Vec<char>,
needle: Vec<char>,
trial: Vec<usize>,
best: Vec<usize>,
}
thread_local! {
static SCRATCH: std::cell::RefCell<Scratch> = std::cell::RefCell::new(Scratch::default());
}
pub fn best_match_with<T>(
needle: &str,
haystack: &str,
answer: impl FnOnce(i32, &[usize]) -> T,
) -> Option<T> {
if needle.is_empty() {
return Some(answer(0, &[]));
}
let ascii = needle.is_ascii() && haystack.is_ascii();
if ascii && !ascii_subsequence(haystack, needle) {
return None;
}
SCRATCH.with(|scratch| match scratch.try_borrow_mut() {
Ok(mut scratch) => score_best(&mut scratch, ascii, needle, haystack, answer),
Err(_) => score_best(&mut Scratch::default(), ascii, needle, haystack, answer),
})
}
fn score_best<T>(
scratch: &mut Scratch,
ascii: bool,
needle: &str,
haystack: &str,
answer: impl FnOnce(i32, &[usize]) -> T,
) -> Option<T> {
let Scratch {
hay,
lower,
needle: need,
trial,
best,
} = scratch;
hay.clear();
lower.clear();
need.clear();
hay.extend(haystack.chars());
if ascii {
lower.extend(hay.iter().map(char::to_ascii_lowercase));
need.extend(needle.chars().map(|c| c.to_ascii_lowercase()));
} else {
lower.extend(haystack.to_lowercase().chars());
need.extend(needle.to_lowercase().chars());
}
if lower.len() != hay.len() {
return simple_match(hay, need).map(|m| answer(m.score, &m.positions));
}
if need.len() > hay.len() {
return None;
}
if !subsequence(lower, need) {
return None;
}
if lower == need {
let score = score_from(hay, lower, need, 0, best)?;
return Some(answer(score + EXACT_BONUS, best));
}
let mut top: Option<i32> = None;
for start in 0..hay.len() {
if lower[start] != need[0] {
continue;
}
if let Some(score) = score_from(hay, lower, need, start, trial) {
if top.is_none_or(|t| score > t) {
top = Some(score);
std::mem::swap(trial, best);
}
} else {
break;
}
}
top.map(|score| answer(score, best))
}
fn simple_match(hay: &[char], needle: &[char]) -> Option<Match> {
let mut positions = Vec::with_capacity(needle.len());
let mut hi = 0usize;
for nc in needle {
let found =
(hi..hay.len()).find(|i| hay[*i].to_lowercase().next().is_some_and(|c| c == *nc))?;
positions.push(found);
hi = found + 1;
}
Some(Match {
score: (positions.len() as i32) * SCORE_MATCH,
positions,
})
}
fn ascii_subsequence(haystack: &str, needle: &str) -> bool {
let mut hay = haystack.bytes();
needle
.bytes()
.all(|n| hay.any(|h| h.eq_ignore_ascii_case(&n)))
}
fn subsequence(lower: &[char], needle: &[char]) -> bool {
let mut hi = 0usize;
for nc in needle {
match (hi..lower.len()).find(|i| lower[*i] == *nc) {
Some(found) => hi = found + 1,
None => return false,
}
}
true
}
pub fn is_match(needle: &str, haystack: &str) -> bool {
if needle.is_empty() {
return true;
}
let mut chars = haystack.chars().flat_map(|c| c.to_lowercase());
'outer: for nc in needle.chars().flat_map(|c| c.to_lowercase()) {
for hc in chars.by_ref() {
if hc == nc {
continue 'outer;
}
}
return false;
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_exact_name_outranks_the_same_word_after_a_boundary() {
let exact = best_match("hour", "hour").unwrap().score;
let inside = best_match("hour", "time_hour").unwrap().score;
assert!(exact > inside, "{exact} vs {inside}");
assert!(best_match("HOUR", "hour").unwrap().score > inside);
assert_eq!(best_match("hour", "hour").unwrap().positions, [0, 1, 2, 3]);
}
#[test]
fn substring_rank_puts_the_name_then_its_start_then_the_rest() {
assert_eq!(substring_rank("Hour", "hour"), Some(0));
assert_eq!(substring_rank("hour", "hours"), Some(1));
assert_eq!(substring_rank("hour", "time_hour"), Some(2));
assert_eq!(substring_rank("hour", "minute"), None);
assert_eq!(substring_rank("", "minute"), Some(2));
}
}