352 lines
11 KiB
Rust
352 lines
11 KiB
Rust
//! Local surfaces use the scoring here surfaces backed by a remote provider
|
|
//! take only the normalised text since ranking their results is their job
|
|
|
|
/// Trims and collapses internal whitespace runs so spacing variants share one
|
|
/// provider request cache key and filter result
|
|
#[must_use]
|
|
pub fn normalize_query(raw: &str) -> String {
|
|
let mut out = String::with_capacity(raw.len());
|
|
for word in raw.split_whitespace() {
|
|
if !out.is_empty() {
|
|
out.push(' ');
|
|
}
|
|
out.push_str(word);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Ordered worst to best
|
|
/// The tier dominates the within-tier bonus can never lift a weaker kind of
|
|
/// match above a stronger one
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub struct MatchScore(u32);
|
|
|
|
impl MatchScore {
|
|
/// Awarded to every candidate by an empty query preserving the caller's order
|
|
pub const NEUTRAL: Self = Self(0);
|
|
}
|
|
|
|
/// Tier floors
|
|
/// The gap between them is wider than any bonus below can reach
|
|
const TIER_EXACT: u32 = 5_000;
|
|
const TIER_PREFIX: u32 = 4_000;
|
|
const TIER_WORD_PREFIX: u32 = 3_000;
|
|
const TIER_SUBSTRING: u32 = 2_000;
|
|
const TIER_SUBSEQUENCE: u32 = 1_000;
|
|
const TIER_TYPO: u32 = 100;
|
|
|
|
/// Ceiling for the within-tier bonus comfortably below the 1000-wide tier gap
|
|
const BONUS_MAX: u32 = 900;
|
|
|
|
/// Below this many characters a typo budget would match almost anything so
|
|
/// short terms are held to the exact tiers
|
|
const MIN_TYPO_LEN: usize = 4;
|
|
|
|
/// Normalised once so matching a list of candidates doesn't redo the work per row
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct SearchQuery {
|
|
text: String,
|
|
terms: Vec<String>,
|
|
}
|
|
|
|
impl SearchQuery {
|
|
#[must_use]
|
|
pub fn new(raw: &str) -> Self {
|
|
let text = normalize_query(raw).to_lowercase();
|
|
let terms = text
|
|
.split(' ')
|
|
.filter(|t| !t.is_empty())
|
|
.map(str::to_string)
|
|
.collect();
|
|
Self { text, terms }
|
|
}
|
|
|
|
/// Safe as a cache key inputs differing only in spacing produce the same text
|
|
#[must_use]
|
|
pub fn as_str(&self) -> &str {
|
|
&self.text
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.text.is_empty()
|
|
}
|
|
|
|
/// `None` if it doesn't match
|
|
/// An empty query matches everything at [`MatchScore::NEUTRAL`]
|
|
#[must_use]
|
|
pub fn score(&self, haystack: &str) -> Option<MatchScore> {
|
|
if self.is_empty() {
|
|
return Some(MatchScore::NEUTRAL);
|
|
}
|
|
|
|
let hay = normalize_query(haystack).to_lowercase();
|
|
if hay.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
if let Some(score) = score_whole(&hay, &self.text) {
|
|
return Some(score);
|
|
}
|
|
|
|
// Per-word fallback lets word order differ keeps one typo from sinking
|
|
// the query and gives the typo budget a word rather than a whole name
|
|
let mut worst = u32::MAX;
|
|
for term in &self.terms {
|
|
let best = hay
|
|
.split(' ')
|
|
.filter_map(|word| score_whole(word, term))
|
|
.max()
|
|
.or_else(|| score_whole(&hay, term))?;
|
|
worst = worst.min(best.0);
|
|
}
|
|
// A per-word match must never outrank the tier it matched at
|
|
Some(MatchScore(worst.saturating_sub(1)))
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn best_score<'a>(&self, fields: impl IntoIterator<Item = &'a str>) -> Option<MatchScore> {
|
|
fields.into_iter().filter_map(|f| self.score(f)).max()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn matches(&self, haystack: &str) -> bool {
|
|
self.score(haystack).is_some()
|
|
}
|
|
}
|
|
|
|
/// Both inputs must already be normalised and lowercased
|
|
fn score_whole(hay: &str, needle: &str) -> Option<MatchScore> {
|
|
if hay == needle {
|
|
return Some(MatchScore(TIER_EXACT));
|
|
}
|
|
if hay.starts_with(needle) {
|
|
return Some(MatchScore(TIER_PREFIX + coverage(hay, needle)));
|
|
}
|
|
if let Some(at) = hay.find(needle) {
|
|
// A word-boundary hit reads as intentional mid-word is closer to coincidence
|
|
let word_start = hay[..at].ends_with(' ');
|
|
let tier = if word_start {
|
|
TIER_WORD_PREFIX
|
|
} else {
|
|
TIER_SUBSTRING
|
|
};
|
|
return Some(MatchScore(tier + coverage(hay, needle)));
|
|
}
|
|
if let Some(span) = subsequence_span(hay, needle) {
|
|
// Tightness not just length sets the bonus a scattered subsequence is weaker
|
|
let tightness = scale(needle.chars().count(), span);
|
|
return Some(MatchScore(TIER_SUBSEQUENCE + tightness));
|
|
}
|
|
|
|
let needle_len = needle.chars().count();
|
|
if needle_len < MIN_TYPO_LEN {
|
|
return None;
|
|
}
|
|
let budget = typo_budget(needle_len);
|
|
let distance = osa_distance(hay, needle, budget)?;
|
|
// Fewer typos forgiven ranks higher
|
|
let bonus = BONUS_MAX.saturating_sub((distance as u32) * (BONUS_MAX / (budget as u32 + 1)));
|
|
Some(MatchScore(TIER_TYPO + bonus))
|
|
}
|
|
|
|
/// Within-tier bonus for how much of the candidate the query accounts for
|
|
fn coverage(hay: &str, needle: &str) -> u32 {
|
|
scale(needle.chars().count(), hay.chars().count())
|
|
}
|
|
|
|
fn scale(part: usize, whole: usize) -> u32 {
|
|
if whole == 0 {
|
|
return 0;
|
|
}
|
|
let ratio = (part.min(whole) as u64 * u64::from(BONUS_MAX)) / whole as u64;
|
|
ratio as u32
|
|
}
|
|
|
|
/// Letters a term of this length may get wrong
|
|
/// Deliberately stingy a generous budget makes every query match everything
|
|
fn typo_budget(len: usize) -> usize {
|
|
if len >= 8 { 2 } else { 1 }
|
|
}
|
|
|
|
/// Width of the tightest window of `hay` holding `needle`'s chars in order or
|
|
/// `None` if not a subsequence
|
|
/// Covers dropped/doubled-letter typos
|
|
fn subsequence_span(hay: &str, needle: &str) -> Option<usize> {
|
|
let hay: Vec<char> = hay.chars().collect();
|
|
let needle: Vec<char> = needle.chars().collect();
|
|
if needle.is_empty() || needle.len() > hay.len() {
|
|
return None;
|
|
}
|
|
|
|
// Earliest end then latest start walking back from it the tightest window
|
|
let mut n = 0;
|
|
let mut end = None;
|
|
for (i, c) in hay.iter().enumerate() {
|
|
if *c == needle[n] {
|
|
n += 1;
|
|
if n == needle.len() {
|
|
end = Some(i);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let end = end?;
|
|
|
|
let mut n = needle.len();
|
|
let mut start = 0;
|
|
for i in (0..=end).rev() {
|
|
if hay[i] == needle[n - 1] {
|
|
n -= 1;
|
|
if n == 0 {
|
|
start = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Some(end - start + 1)
|
|
}
|
|
|
|
/// Optimal string alignment distance giving up once it exceeds `max`
|
|
/// Unlike Levenshtein a transposition costs one edit
|
|
fn osa_distance(a: &str, b: &str, max: usize) -> Option<usize> {
|
|
let a: Vec<char> = a.chars().collect();
|
|
let b: Vec<char> = b.chars().collect();
|
|
if a.len().abs_diff(b.len()) > max {
|
|
return None;
|
|
}
|
|
|
|
// Three rows rotate through these buffers `two_ago` is only read from row
|
|
// two onwards by which point it holds real values
|
|
let mut two_ago: Vec<usize> = vec![0; b.len() + 1];
|
|
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
|
let mut cur: Vec<usize> = vec![0; b.len() + 1];
|
|
|
|
for i in 1..=a.len() {
|
|
cur[0] = i;
|
|
let mut row_min = cur[0];
|
|
for j in 1..=b.len() {
|
|
let cost = usize::from(a[i - 1] != b[j - 1]);
|
|
let mut best = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
|
|
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
|
|
best = best.min(two_ago[j - 2] + 1);
|
|
}
|
|
cur[j] = best;
|
|
row_min = row_min.min(best);
|
|
}
|
|
// Every later row is at least this large so the budget is unrecoverable
|
|
if row_min > max {
|
|
return None;
|
|
}
|
|
std::mem::swap(&mut two_ago, &mut prev);
|
|
std::mem::swap(&mut prev, &mut cur);
|
|
}
|
|
|
|
(prev[b.len()] <= max).then_some(prev[b.len()])
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn best(query: &str, candidates: &[&str]) -> Vec<String> {
|
|
let q = SearchQuery::new(query);
|
|
let mut scored: Vec<(MatchScore, &str)> = candidates
|
|
.iter()
|
|
.filter_map(|c| q.score(c).map(|s| (s, *c)))
|
|
.collect();
|
|
scored.sort_by_key(|(s, c)| (std::cmp::Reverse(*s), *c));
|
|
scored.into_iter().map(|(_, c)| c.to_string()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn whitespace_never_changes_a_query() {
|
|
let canonical = SearchQuery::new("sodium extra");
|
|
for raw in [
|
|
" sodium extra",
|
|
"sodium extra ",
|
|
"sodium extra",
|
|
"\tsodium\nextra ",
|
|
] {
|
|
assert_eq!(SearchQuery::new(raw), canonical, "{raw:?}");
|
|
}
|
|
assert_eq!(canonical.as_str(), "sodium extra");
|
|
}
|
|
|
|
#[test]
|
|
fn a_blank_query_is_empty() {
|
|
assert!(SearchQuery::new(" ").is_empty());
|
|
assert_eq!(
|
|
SearchQuery::new("").score("anything"),
|
|
Some(MatchScore::NEUTRAL)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn common_typos_still_find_the_package() {
|
|
for typo in ["sodum", "soduim", "sodiun", "sodiium"] {
|
|
assert!(SearchQuery::new(typo).matches("Sodium"), "{typo}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn short_terms_are_not_fuzzed() {
|
|
assert!(!SearchQuery::new("abc").matches("Sodium"));
|
|
}
|
|
|
|
#[test]
|
|
fn unrelated_names_do_not_match() {
|
|
assert!(!SearchQuery::new("sodium").matches("Journeymap"));
|
|
}
|
|
|
|
#[test]
|
|
fn better_kinds_of_match_rank_first() {
|
|
assert_eq!(
|
|
best(
|
|
"sodium",
|
|
&[
|
|
"Sodium",
|
|
"Sodium Extra",
|
|
"Indium (Sodium addon)",
|
|
"Sodum Fork",
|
|
"Sodiun"
|
|
]
|
|
),
|
|
[
|
|
"Sodium",
|
|
"Sodium Extra",
|
|
"Indium (Sodium addon)",
|
|
"Sodiun",
|
|
"Sodum Fork",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_tight_subsequence_beats_a_scattered_one() {
|
|
assert_eq!(
|
|
best("srr", &["Sodium Rendering Regression", "Sorry"]),
|
|
["Sorry", "Sodium Rendering Regression"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn multi_word_queries_match_out_of_order() {
|
|
let q = SearchQuery::new("extra sodium");
|
|
assert!(q.matches("Sodium Extra"));
|
|
assert!(!q.matches("Sodium"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_typo_in_one_word_does_not_sink_the_query() {
|
|
assert!(SearchQuery::new("sodum extra").matches("Sodium Extra"));
|
|
}
|
|
|
|
#[test]
|
|
fn best_score_picks_the_strongest_field() {
|
|
let q = SearchQuery::new("sodium");
|
|
let whole = q.score("Sodium").unwrap();
|
|
assert_eq!(q.best_score(["Fabric API", "Sodium"]), Some(whole));
|
|
assert_eq!(q.best_score(["Fabric API", "fabric-api.jar"]), None);
|
|
}
|
|
}
|