feat(search): make cluster package search fuzzy (#686)
This commit is contained in:
parent
b60ce5af12
commit
e99437f1b4
10 changed files with 476 additions and 241 deletions
|
|
@ -2,6 +2,7 @@ use std::time::Duration;
|
|||
|
||||
use freya::query::{Query, QueryCapability, UseQuery, use_query};
|
||||
use oneclient_common::domain::GameLoader;
|
||||
use oneclient_common::search::normalize_query;
|
||||
use oneclient_content::packages::types::{
|
||||
DEFAULT_PAGE_SIZE, Page, ProjectDetail, ProjectSummary, SearchFilters, SearchSort,
|
||||
VersionSummary,
|
||||
|
|
@ -53,7 +54,7 @@ impl QueryCapability for PackageSearchQuery {
|
|||
Ok(provider
|
||||
.search(
|
||||
&SearchFilters {
|
||||
query: (!keys.query.trim().is_empty()).then(|| keys.query.trim().to_string()),
|
||||
query: (!keys.query.is_empty()).then(|| keys.query.clone()),
|
||||
content_type: Some(keys.content_type),
|
||||
game_versions: (!keys.game_versions.is_empty())
|
||||
.then(|| keys.game_versions.clone()),
|
||||
|
|
@ -84,7 +85,10 @@ pub fn use_package_search(
|
|||
PackageSearchKeys {
|
||||
provider,
|
||||
content_type,
|
||||
query,
|
||||
// Canonical form, because the key is also the cache key: without it
|
||||
// "sodium" and "sodium " are two entries, and adding a trailing
|
||||
// space throws the results away and refetches for nothing.
|
||||
query: normalize_query(&query),
|
||||
game_versions,
|
||||
loaders,
|
||||
categories,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use freya::animation::*;
|
|||
use freya::prelude::*;
|
||||
use freya::router::*;
|
||||
use freya::text_edit::Clipboard;
|
||||
use oneclient_common::search::{MatchScore, SearchQuery};
|
||||
use sysinfo::CpuRefreshKind;
|
||||
use sysinfo::MemoryRefreshKind;
|
||||
use sysinfo::RefreshKind;
|
||||
|
|
@ -391,7 +392,7 @@ impl Component for SettingsShell {
|
|||
let changelog_unread = has_unread_changelog();
|
||||
|
||||
let search = use_state(String::new);
|
||||
let query = search.read().trim().to_string();
|
||||
let query = SearchQuery::new(&search.read());
|
||||
let scroll = use_scroll_controller(ScrollConfig::default);
|
||||
SETTINGS_SCROLL_CONTROLLER.with(|cell| *cell.borrow_mut() = Some(scroll));
|
||||
|
||||
|
|
@ -420,7 +421,7 @@ impl Component for SettingsShell {
|
|||
.into_element(),
|
||||
]
|
||||
} else {
|
||||
search_results(query, search)
|
||||
search_results(&query, search)
|
||||
};
|
||||
|
||||
rect()
|
||||
|
|
@ -504,19 +505,35 @@ fn search_box(mut search: State<String>) -> impl IntoElement {
|
|||
)
|
||||
}
|
||||
|
||||
fn search_results(query: String, mut search: State<String>) -> Vec<Element> {
|
||||
let q = query.to_lowercase();
|
||||
let mut matches: Vec<SearchItem> = SEARCH_INDEX
|
||||
/// How well a settings entry answers the query. The title is what the user is
|
||||
/// looking for, so a hit there outranks one in the blurb or the hidden keywords
|
||||
/// however well those scored — a description match is a hint, not the thing.
|
||||
fn settings_score(item: &SearchItem, query: &SearchQuery) -> Option<(u8, MatchScore)> {
|
||||
if let Some(score) = query.score(item.title) {
|
||||
return Some((2, score));
|
||||
}
|
||||
if let Some(score) = query.best_score(item.keywords.iter().copied()) {
|
||||
return Some((1, score));
|
||||
}
|
||||
query.score(item.description).map(|score| (0, score))
|
||||
}
|
||||
|
||||
fn search_results(query: &SearchQuery, mut search: State<String>) -> Vec<Element> {
|
||||
let mut scored: Vec<(u8, MatchScore, SearchItem)> = SEARCH_INDEX
|
||||
.iter()
|
||||
.filter(|&item| {
|
||||
item.title.to_lowercase().contains(&q)
|
||||
|| item.description.to_lowercase().contains(&q)
|
||||
|| item.keywords.iter().any(|k| k.to_lowercase().contains(&q))
|
||||
})
|
||||
.cloned()
|
||||
.filter_map(|item| settings_score(item, query).map(|(f, s)| (f, s, item.clone())))
|
||||
.collect();
|
||||
|
||||
matches.sort_by_key(|m| m.title.len());
|
||||
// Best field, then best score, then the shortest title — the entry whose
|
||||
// name is closest to being just the query.
|
||||
scored.sort_by_key(|(field, score, item)| {
|
||||
(
|
||||
std::cmp::Reverse(*field),
|
||||
std::cmp::Reverse(*score),
|
||||
item.title.len(),
|
||||
)
|
||||
});
|
||||
let mut matches: Vec<SearchItem> = scored.into_iter().map(|(_, _, item)| item).collect();
|
||||
matches.truncate(SEARCH_RESULTS_MAX);
|
||||
|
||||
if matches.is_empty() {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use freya::animation::{
|
|||
use freya::prelude::*;
|
||||
use freya::router::RouterContext;
|
||||
use oneclient_common::parse_mc_version;
|
||||
use oneclient_common::search::normalize_query;
|
||||
use oneclient_content::packages::types::ProjectSummary;
|
||||
use oneclient_content::packages::types::SearchSort;
|
||||
use oneclient_content::packages::{ContentType, ProviderId};
|
||||
|
|
@ -127,9 +128,11 @@ impl Component for Browser {
|
|||
};
|
||||
|
||||
let mut page_state = page;
|
||||
// Normalised the same way the search key is, so a query that only picked
|
||||
// up a space is not treated as a new search that resets the page.
|
||||
let signature = format!(
|
||||
"{provider_id:?}|{}|{compat}|{}",
|
||||
debounced_query.read().trim().to_lowercase(),
|
||||
normalize_query(&debounced_query.read()).to_lowercase(),
|
||||
cats.join(",")
|
||||
);
|
||||
let mut last_signature = use_state(|| signature.clone());
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::sync::Arc;
|
|||
use freya::prelude::*;
|
||||
use freya::query::MutationStateData;
|
||||
use freya::text_edit::Clipboard;
|
||||
use oneclient_common::search::SearchQuery;
|
||||
use oneclient_core::{LogFileInfo, LogKind, LogLevel};
|
||||
|
||||
use crate::components::{
|
||||
|
|
@ -366,14 +367,10 @@ impl Component for LogPicker {
|
|||
let on_select = self.on_select.clone();
|
||||
let is_open = open() && enabled;
|
||||
|
||||
let query = filter.read().to_lowercase();
|
||||
let query = SearchQuery::new(&filter.read());
|
||||
let rows: Vec<Element> = files
|
||||
.into_iter()
|
||||
.filter(|f| {
|
||||
query.is_empty()
|
||||
|| f.name.to_lowercase().contains(&query)
|
||||
|| kind_badge(f.kind).to_lowercase().contains(&query)
|
||||
})
|
||||
.filter(|f| query.matches(&f.name) || query.matches(kind_badge(f.kind)))
|
||||
.map(|file| {
|
||||
let on_select = on_select.clone();
|
||||
let path = file.path.clone();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use freya::prelude::*;
|
||||
use oneclient_common::search::{MatchScore, SearchQuery};
|
||||
use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId};
|
||||
use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo};
|
||||
use oneclient_db::models::OverrideType;
|
||||
|
|
@ -287,11 +288,10 @@ impl Tab {
|
|||
}
|
||||
}
|
||||
|
||||
/// `query` is expected to be lowercased already.
|
||||
fn matches_query(p: &PackageEntry, query: &str) -> bool {
|
||||
query.is_empty()
|
||||
|| p.name.to_lowercase().contains(query)
|
||||
|| p.file_name.to_lowercase().contains(query)
|
||||
/// A package is searched by what the user sees (its display name) and by what
|
||||
/// is on disk (its file name), whichever matches better.
|
||||
fn query_score(p: &PackageEntry, query: &SearchQuery) -> Option<MatchScore> {
|
||||
query.best_score([p.name.as_str(), p.file_name.as_str()])
|
||||
}
|
||||
|
||||
/// Rows to show for the current toolbar state. The search is scoped to the
|
||||
|
|
@ -302,18 +302,25 @@ fn matches_query(p: &PackageEntry, query: &str) -> bool {
|
|||
fn visible_packages(
|
||||
items: &[PackageEntry],
|
||||
tab: Option<&Tab>,
|
||||
query: &str,
|
||||
query: &SearchQuery,
|
||||
show: EnabledFilter,
|
||||
hidden: HiddenFilter,
|
||||
) -> Vec<PackageEntry> {
|
||||
items
|
||||
.iter()
|
||||
.filter(|p| tab.is_none_or(|t| t.matches(p)) && matches_query(p, query))
|
||||
.filter(|p| tab.is_none_or(|t| t.matches(p)) && query_score(p, query).is_some())
|
||||
.filter(|p| show.keep(p) && hidden.keep(p))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reorders search results so the closest matches come first. Stable, so the
|
||||
/// sort the user picked in the toolbar survives as the tie-break between rows
|
||||
/// that matched equally well.
|
||||
fn rank_by_query(rows: &mut [PackageEntry], query: &SearchQuery) {
|
||||
rows.sort_by_key(|p| std::cmp::Reverse(query_score(p, query)));
|
||||
}
|
||||
|
||||
pub fn bundle_categories(bundles: &[BundleWithUpdateStatus]) -> Vec<String> {
|
||||
let mut cats: Vec<String> = Vec::new();
|
||||
for bundle in bundles {
|
||||
|
|
@ -403,7 +410,7 @@ impl Component for PackageManager {
|
|||
let view = use_view_state("cluster.packages");
|
||||
let sort = view.sort;
|
||||
let layout = view.layout;
|
||||
let query = search.read().to_lowercase();
|
||||
let query = SearchQuery::new(&search.read());
|
||||
let sort_mode = sort
|
||||
.read()
|
||||
.as_deref()
|
||||
|
|
@ -420,6 +427,12 @@ impl Component for PackageManager {
|
|||
|
||||
let mut filtered = visible_packages(&items, tab_filter, &query, show, hidden);
|
||||
sort_mode.sort(&mut filtered);
|
||||
// Relevance has to win while searching: with fuzzy matching, a name that
|
||||
// only matched after forgiving a typo would otherwise sort above the one
|
||||
// the user actually typed.
|
||||
if !query.is_empty() {
|
||||
rank_by_query(&mut filtered, &query);
|
||||
}
|
||||
|
||||
// Coming up empty during a search is about the query, not about the tab
|
||||
// having nothing in it, so it gets its own empty state naming the tab
|
||||
|
|
@ -468,212 +481,3 @@ impl Component for PackageManager {
|
|||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::view::onboarding::test_support::{archive, file};
|
||||
use oneclient_core::{BundleFile, FileUpdateStatus};
|
||||
|
||||
fn bundle(category: &str, files: Vec<BundleFile>) -> BundleWithUpdateStatus {
|
||||
BundleWithUpdateStatus {
|
||||
files: files
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|f| (f, FileUpdateStatus::UpToDate))
|
||||
.collect(),
|
||||
archive: archive(category, true, files),
|
||||
has_updates: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn installed(package_id: &str) -> LinkedArtifactInfo {
|
||||
LinkedArtifactInfo {
|
||||
hash: package_id.to_string(),
|
||||
cluster_file_name: format!("{package_id}.jar"),
|
||||
enabled: true,
|
||||
content_type: ContentType::Mod,
|
||||
file_name: format!("{package_id}.jar"),
|
||||
project_id: None,
|
||||
version_id: None,
|
||||
display_name: None,
|
||||
display_version: None,
|
||||
provider: None,
|
||||
published_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rows_for(
|
||||
content: Vec<LinkedArtifactInfo>,
|
||||
bundles: &[BundleWithUpdateStatus],
|
||||
) -> Vec<PackageEntry> {
|
||||
bundle_packages(
|
||||
content,
|
||||
bundles,
|
||||
&HashMap::new(),
|
||||
&PackageMetaMap::new(),
|
||||
&HashSet::new(),
|
||||
ContentType::Mod,
|
||||
)
|
||||
}
|
||||
|
||||
fn ids(rows: &[PackageEntry]) -> Vec<String> {
|
||||
rows.iter().map(|r| r.package_id.clone()).collect()
|
||||
}
|
||||
|
||||
fn shown(rows: &[PackageEntry], hidden: HiddenFilter) -> Vec<String> {
|
||||
ids(&visible_packages(
|
||||
rows,
|
||||
None,
|
||||
"",
|
||||
EnabledFilter::All,
|
||||
hidden,
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_bundle_files_are_not_listed_by_default() {
|
||||
let bundles = vec![bundle(
|
||||
"Core",
|
||||
vec![file("visible", true, false), file("dep", true, true)],
|
||||
)];
|
||||
let rows = rows_for(Vec::new(), &bundles);
|
||||
assert_eq!(shown(&rows, HiddenFilter::Hide), ["visible"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_the_filter_off_reveals_hidden_bundle_files() {
|
||||
let bundles = vec![bundle(
|
||||
"Core",
|
||||
vec![file("visible", true, false), file("dep", true, true)],
|
||||
)];
|
||||
let rows = rows_for(Vec::new(), &bundles);
|
||||
assert_eq!(shown(&rows, HiddenFilter::Show), ["visible", "dep"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installed_hidden_file_does_not_come_back_as_loose_content() {
|
||||
// The dependency really is in the cluster, so the pass over installed
|
||||
// content would happily list it as a second, unmanaged local row.
|
||||
let bundles = vec![bundle("Core", vec![file("dep", true, true)])];
|
||||
let rows = rows_for(vec![installed("dep")], &bundles);
|
||||
assert_eq!(ids(&rows), ["dep"]);
|
||||
assert!(rows[0].hidden && rows[0].in_bundle());
|
||||
assert!(shown(&rows, HiddenFilter::Hide).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_category_of_only_hidden_files_gets_no_tab() {
|
||||
let bundles = vec![bundle("Core", vec![file("dep", true, true)])];
|
||||
let rows = rows_for(Vec::new(), &bundles);
|
||||
let labels = |hidden| -> Vec<String> {
|
||||
build_tabs(&bundle_categories(&bundles), &rows, hidden)
|
||||
.iter()
|
||||
.map(Tab::label)
|
||||
.collect()
|
||||
};
|
||||
assert!(!labels(HiddenFilter::Hide).contains(&"Core".to_string()));
|
||||
assert!(labels(HiddenFilter::Show).contains(&"Core".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_another_bundle_shows_openly_stays_visible() {
|
||||
let bundles = vec![
|
||||
bundle("Core", vec![file("shared", true, true)]),
|
||||
bundle("Extras", vec![file("shared", true, false)]),
|
||||
];
|
||||
let rows = rows_for(Vec::new(), &bundles);
|
||||
assert_eq!(ids(&rows), ["shared"]);
|
||||
assert_eq!(
|
||||
rows[0].bundle_name.as_deref(),
|
||||
Some("OneClient 1.21.11 Fabric [Extras]")
|
||||
);
|
||||
}
|
||||
|
||||
fn entry(name: &str, provider: ProviderId, categories: &[&str]) -> PackageEntry {
|
||||
PackageEntry {
|
||||
package_id: name.to_string(),
|
||||
bundle_name: (!categories.is_empty()).then(|| "bundle".to_string()),
|
||||
provider,
|
||||
name: name.to_string(),
|
||||
file_name: format!("{}.jar", name.to_lowercase()),
|
||||
author: String::new(),
|
||||
description: String::new(),
|
||||
icon_url: None,
|
||||
size: 0,
|
||||
categories: categories.iter().map(|c| (*c).to_string()).collect(),
|
||||
enabled: true,
|
||||
manifest_default: true,
|
||||
installed: true,
|
||||
hash: None,
|
||||
update_available: false,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn items() -> Vec<PackageEntry> {
|
||||
vec![
|
||||
entry("Sodium", ProviderId::Modrinth, &["Performance"]),
|
||||
entry("Sodium Extra", ProviderId::Modrinth, &["Visuals"]),
|
||||
entry("Sodium Local", ProviderId::Local, &[]),
|
||||
]
|
||||
}
|
||||
|
||||
fn names(rows: &[PackageEntry]) -> Vec<&str> {
|
||||
rows.iter().map(|p| p.name.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_stays_inside_the_active_category() {
|
||||
let items = items();
|
||||
let tab = Tab::Category("Performance".to_string());
|
||||
let rows = visible_packages(
|
||||
&items,
|
||||
Some(&tab),
|
||||
"sodium",
|
||||
EnabledFilter::All,
|
||||
HiddenFilter::Hide,
|
||||
);
|
||||
assert_eq!(names(&rows), ["Sodium"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_stays_inside_the_local_tab() {
|
||||
let items = items();
|
||||
let rows = visible_packages(
|
||||
&items,
|
||||
Some(&Tab::Local),
|
||||
"sodium",
|
||||
EnabledFilter::All,
|
||||
HiddenFilter::Hide,
|
||||
);
|
||||
assert_eq!(names(&rows), ["Sodium Local"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_tab_searches_every_package() {
|
||||
let items = items();
|
||||
let rows = visible_packages(
|
||||
&items,
|
||||
Some(&Tab::All),
|
||||
"sodium",
|
||||
EnabledFilter::All,
|
||||
HiddenFilter::Hide,
|
||||
);
|
||||
assert_eq!(names(&rows), ["Sodium", "Sodium Extra", "Sodium Local"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_query_keeps_the_whole_tab() {
|
||||
let items = items();
|
||||
let tab = Tab::Category("Visuals".to_string());
|
||||
let rows = visible_packages(
|
||||
&items,
|
||||
Some(&tab),
|
||||
"",
|
||||
EnabledFilter::All,
|
||||
HiddenFilter::Hide,
|
||||
);
|
||||
assert_eq!(names(&rows), ["Sodium Extra"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub mod domain;
|
|||
pub mod os_ext;
|
||||
pub mod paths;
|
||||
pub mod patch;
|
||||
pub mod search;
|
||||
pub mod version;
|
||||
|
||||
mod error;
|
||||
|
|
@ -21,4 +22,5 @@ pub use domain::{
|
|||
pub use error::{PathsError, PathsResult};
|
||||
pub use os_ext::OsExt;
|
||||
pub use patch::Patch;
|
||||
pub use search::{MatchScore, SearchQuery, normalize_query};
|
||||
pub use version::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version};
|
||||
|
|
|
|||
394
packages/oneclient_common/src/search.rs
Normal file
394
packages/oneclient_common/src/search.rs
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
//! Query normalisation and typo-tolerant matching for the launcher's search
|
||||
//! boxes.
|
||||
//!
|
||||
//! Every search surface — the package browser, a cluster's package list, the
|
||||
//! settings index — funnels its raw text box through [`SearchQuery`] so they all
|
||||
//! agree on what a query *is*. Surfaces that match locally then use the scoring
|
||||
//! here; surfaces that hand the query to a remote provider only take the
|
||||
//! normalised text, because ranking a provider's results is the provider's job.
|
||||
|
||||
/// Collapses a text box's raw contents into the canonical form every search
|
||||
/// agrees on: no leading or trailing whitespace, internal runs of whitespace
|
||||
/// reduced to a single space.
|
||||
///
|
||||
/// Without this, `"sodium"`, `" sodium"` and `"sodium extra"` are three
|
||||
/// different searches — different provider requests, different cache keys, and
|
||||
/// for a plain `contains` filter, different results.
|
||||
#[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
|
||||
}
|
||||
|
||||
/// How well a candidate matched. Ordered worst to best, so a list of candidates
|
||||
/// sorts into relevance order with `sort_by_key(|c| Reverse(score))`.
|
||||
///
|
||||
/// The tier dominates the comparison: a prefix match always beats a substring
|
||||
/// match, which always beats a subsequence match, which always beats a
|
||||
/// typo-tolerant one. The remainder only orders candidates *within* a tier, so
|
||||
/// the ordering never surprises — a worse kind of match cannot climb above a
|
||||
/// better one by scoring well.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct MatchScore(u32);
|
||||
|
||||
impl MatchScore {
|
||||
/// What an empty query awards every candidate: nothing is more relevant than
|
||||
/// anything else, so whatever order the caller already had survives.
|
||||
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;
|
||||
|
||||
/// A search box's contents, normalised once so matching a list of candidates
|
||||
/// against it doesn't redo the work per row.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct SearchQuery {
|
||||
/// Normalised and lowercased; this is what gets matched and what a remote
|
||||
/// provider should be sent.
|
||||
text: String,
|
||||
/// `text` split on spaces. Multi-word queries fall back to matching each
|
||||
/// word independently, so "sodum extra" can still reach "Sodium Extra".
|
||||
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 }
|
||||
}
|
||||
|
||||
/// The normalised query text. Safe to use as a cache key or to send to a
|
||||
/// provider — two raw inputs differing only in spacing produce the same one.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.is_empty()
|
||||
}
|
||||
|
||||
/// Scores `haystack`, or `None` if it doesn't match at all.
|
||||
///
|
||||
/// 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);
|
||||
}
|
||||
|
||||
// The query as one string didn't land, so try it as independent words
|
||||
// against the candidate's: every term has to hit one, and the weakest
|
||||
// hit sets the tier. This is what lets word order differ ("extra
|
||||
// sodium"), what keeps a typo in one word from sinking the whole query,
|
||||
// and what gives the typo budget a word to work against instead of a
|
||||
// whole name it could never be close enough to.
|
||||
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 is a weaker claim than a whole-query one, so it can
|
||||
// never outrank the tier it matched at.
|
||||
Some(MatchScore(worst.saturating_sub(1)))
|
||||
}
|
||||
|
||||
/// The best score across several fields of the same candidate — a package's
|
||||
/// display name and its file name, say. `None` if no field matched.
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
/// Scores one already-normalised, already-lowercased pair.
|
||||
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) {
|
||||
// Landing on a word boundary ("extra" in "Sodium Extra") reads as a real
|
||||
// hit; landing mid-word ("ode" in "Sodium") is closer to a 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) {
|
||||
// A subsequence spread over the whole name is a weaker match than one
|
||||
// packed together, so tightness — not just length — sets the bonus.
|
||||
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 to forgive is a better match; an exact-length hit with one
|
||||
// wrong letter should beat one with two.
|
||||
let bonus = BONUS_MAX.saturating_sub((distance as u32) * (BONUS_MAX / (budget as u32 + 1)));
|
||||
Some(MatchScore(TIER_TYPO + bonus))
|
||||
}
|
||||
|
||||
/// How much of the candidate the query accounts for, as a within-tier bonus.
|
||||
/// "Sodium" hit by "sodiu" outranks "Sodium Extra Extras" hit by the same.
|
||||
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 turns every search into a match for everything.
|
||||
fn typo_budget(len: usize) -> usize {
|
||||
if len >= 8 { 2 } else { 1 }
|
||||
}
|
||||
|
||||
/// The width of the tightest window of `hay` containing `needle`'s characters in
|
||||
/// order, or `None` if it isn't a subsequence at all. Covers the typo class a
|
||||
/// dropped or doubled letter produces ("sodum", "sodiium").
|
||||
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;
|
||||
}
|
||||
|
||||
// Walk forward for the end of the earliest match, then walk back from there
|
||||
// for the latest start that still matches — that pair is the tightest window
|
||||
// around this match.
|
||||
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 as soon as it exceeds `max`.
|
||||
/// Unlike a plain Levenshtein it counts a transposition ("soduim") as one edit,
|
||||
/// which is the typo people actually make when typing quickly.
|
||||
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 ever 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 there is no way back under
|
||||
// the budget from here.
|
||||
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() {
|
||||
// Dropped letter, transposition, wrong letter, doubled letter.
|
||||
for typo in ["sodum", "soduim", "sodiun", "sodiium"] {
|
||||
assert!(SearchQuery::new(typo).matches("Sodium"), "{typo}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_terms_are_not_fuzzed() {
|
||||
// Two edits away from half the alphabet; a budget here is noise.
|
||||
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"
|
||||
]
|
||||
),
|
||||
[
|
||||
// exact, then prefix (shorter first), then word-boundary
|
||||
// substring, then the typo-tolerant tail.
|
||||
"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);
|
||||
}
|
||||
}
|
||||
|
|
@ -58,8 +58,9 @@ impl PackageProvider for CurseForgeProvider {
|
|||
params.append_pair("index", &filters.offset.unwrap_or(0).to_string());
|
||||
params.append_pair("sortField", "6");
|
||||
params.append_pair("sortOrder", "desc");
|
||||
if let Some(q) = &filters.query {
|
||||
params.append_pair("searchFilter", q);
|
||||
let query = filters.normalized_query();
|
||||
if !query.is_empty() {
|
||||
params.append_pair("searchFilter", &query);
|
||||
}
|
||||
if let Some(v) = filters.game_versions.as_ref().and_then(|v| v.first()) {
|
||||
params.append_pair("gameVersion", v);
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ impl PackageProvider for ModrinthProvider {
|
|||
let mut params = url.query_pairs_mut();
|
||||
params.append_pair("limit", &limit.to_string());
|
||||
params.append_pair("offset", &offset.to_string());
|
||||
params.append_pair("query", filters.query.as_deref().unwrap_or_default());
|
||||
params.append_pair("query", &filters.normalized_query());
|
||||
|
||||
let mut groups: Vec<Vec<String>> = Vec::new();
|
||||
if let Some(content_type) = filters.content_type {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use chrono::{DateTime, Utc};
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
|
||||
use oneclient_common::search::normalize_query;
|
||||
|
||||
pub const DEFAULT_PAGE_SIZE: usize = 24;
|
||||
|
||||
|
|
@ -19,6 +20,18 @@ pub struct SearchFilters {
|
|||
pub sort: Option<SearchSort>,
|
||||
}
|
||||
|
||||
impl SearchFilters {
|
||||
/// The query as it should go on the wire: no surrounding whitespace, and
|
||||
/// internal runs collapsed to a single space. Providers score the raw string
|
||||
/// they are given, so " sodium " and "sodium" come back ranked differently —
|
||||
/// normalising at the boundary is what stops a stray keystroke from changing
|
||||
/// the results. Empty when there is nothing to search for.
|
||||
#[must_use]
|
||||
pub fn normalized_query(&self) -> String {
|
||||
self.query.as_deref().map(normalize_query).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SearchSort {
|
||||
#[default]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue