#![allow(
clippy::cast_possible_wrap,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
reason = "1:1 port of Ruby integer/float arithmetic; widths, cursor positions, and \
list lengths are bounded by terminal size and directory counts"
)]
pub struct Entry<T> {
pub data: T,
text_char_len: usize,
text_lower: Vec<char>,
base_score: f64,
}
impl<T> Entry<T> {
pub fn new(data: T, text: &str, base_score: f64) -> Self {
let text_lower: Vec<char> = text.chars().flat_map(char::to_lowercase).collect();
Self {
data,
text_char_len: text.chars().count(),
text_lower,
base_score,
}
}
}
pub struct Match<'a, T> {
pub data: &'a T,
pub positions: Vec<usize>,
pub score: f64,
}
fn calculate_match<T>(
entry: &Entry<T>,
query: &str,
query_chars: &[char],
) -> Option<(f64, Vec<usize>)> {
let mut positions = Vec::new();
let mut score = entry.base_score;
if query.is_empty() {
return Some((score, positions));
}
let text = &entry.text_lower;
let query_len = query_chars.len();
let mut last_pos: i64 = -1;
let mut pos: usize = 0;
for &qc in query_chars {
let found = text[pos.min(text.len())..]
.iter()
.position(|&c| c == qc)
.map(|i| i + pos)?;
positions.push(found);
score += 1.0;
if found == 0 || {
let prev = text[found - 1];
!(prev.is_ascii_lowercase() || prev.is_ascii_digit())
} {
score += 1.0;
}
if last_pos >= 0 {
#[allow(clippy::cast_sign_loss, reason = "found > last_pos >= 0 here")]
let gap = (found as i64 - last_pos - 1) as u64;
#[allow(clippy::cast_precision_loss, reason = "gap is tiny in practice")]
{
score += 2.0 / ((gap + 1) as f64).sqrt();
}
}
last_pos = found as i64;
pos = found + 1;
}
if last_pos >= 0 {
#[allow(clippy::cast_precision_loss, reason = "lengths are tiny")]
{
score *= query_len as f64 / (last_pos + 1) as f64;
}
}
#[allow(clippy::cast_precision_loss, reason = "lengths are tiny")]
{
score *= 10.0 / (entry.text_char_len as f64 + 10.0);
}
Some((score, positions))
}
pub fn match_entries<'a, T>(
entries: &'a [Entry<T>],
query: &str,
limit: Option<usize>,
) -> Vec<Match<'a, T>> {
let query_lower: String = query.chars().flat_map(char::to_lowercase).collect();
let query_chars: Vec<char> = query_lower.chars().collect();
let mut results: Vec<Match<'a, T>> = entries
.iter()
.filter_map(|e| {
calculate_match(e, query, &query_chars).map(|(score, positions)| Match {
data: &e.data,
positions,
score,
})
})
.collect();
results.sort_by(|a, b| b.score.total_cmp(&a.score));
if let Some(n) = limit {
results.truncate(n);
}
results
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(text: &str, base: f64) -> Entry<String> {
Entry::new(text.to_string(), text, base)
}
fn score_of(text: &str, base: f64, query: &str) -> Option<(f64, Vec<usize>)> {
let e = entry(text, base);
let ql: String = query.chars().flat_map(char::to_lowercase).collect();
let qc: Vec<char> = ql.chars().collect();
calculate_match(&e, query, &qc)
}
#[test]
fn empty_query_returns_raw_base_score_before_multipliers() {
let (score, positions) = score_of("a-very-long-name", 3.25, "").unwrap();
assert_eq!(score.to_bits(), 3.25f64.to_bits());
assert!(positions.is_empty());
}
#[test]
fn all_query_chars_must_match_in_order() {
assert!(score_of("abc", 0.0, "ac").is_some());
assert!(score_of("abc", 0.0, "ca").is_none());
assert!(score_of("abc", 0.0, "abd").is_none());
}
#[test]
fn hand_computed_exact_score() {
let (score, positions) = score_of("ab", 0.0, "ab").unwrap();
let expected = 5.0f64 * (2.0 / 2.0) * (10.0 / 12.0);
assert_eq!(score.to_bits(), expected.to_bits());
assert_eq!(positions, vec![0, 1]);
}
#[test]
fn expanding_downcase_matches_ruby() {
let (score, positions) = score_of("İstanbul", 1.0, "ist").unwrap();
let expected = (1.0f64 + 7.0 + 2.0 / 2.0f64.sqrt()) * (3.0 / 4.0) * (10.0 / 18.0);
assert_eq!(score.to_bits(), expected.to_bits());
assert_eq!(positions, vec![0, 2, 3]);
}
#[test]
fn sort_descending_and_limit() {
let entries = vec![entry("aaa", 1.0), entry("aab", 5.0), entry("abc", 3.0)];
let m = match_entries(&entries, "a", Some(2));
assert_eq!(m.len(), 2);
assert!(m[0].score >= m[1].score);
}
#[test]
fn word_boundary_is_ascii_only() {
let (with_boundary, _) = score_of("éa", 0.0, "a").unwrap();
let (without, _) = score_of("ba", 0.0, "a").unwrap();
assert!(with_boundary > without);
}
}