From e99437f1b44ba18fe2ba35b4bbb98cce79002d1f Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:41:36 +0200 Subject: [PATCH] feat(search): make cluster package search fuzzy (#686) --- .../src/hooks/queries/packages.rs | 8 +- .../src/layout/settings_shell.rs | 41 +- .../src/view/app/browser/index/mod.rs | 5 +- .../src/view/app/cluster/logs.rs | 9 +- .../view/app/cluster/package_manager/mod.rs | 238 +---------- packages/oneclient_common/src/lib.rs | 2 + packages/oneclient_common/src/search.rs | 394 ++++++++++++++++++ .../src/packages/provider/curseforge.rs | 5 +- .../src/packages/provider/modrinth.rs | 2 +- .../oneclient_content/src/packages/types.rs | 13 + 10 files changed, 476 insertions(+), 241 deletions(-) create mode 100644 packages/oneclient_common/src/search.rs diff --git a/packages/oneclient_app/src/hooks/queries/packages.rs b/packages/oneclient_app/src/hooks/queries/packages.rs index 7b826c7e..901dd321 100644 --- a/packages/oneclient_app/src/hooks/queries/packages.rs +++ b/packages/oneclient_app/src/hooks/queries/packages.rs @@ -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, diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs index dfb59f62..0d964860 100644 --- a/packages/oneclient_app/src/layout/settings_shell.rs +++ b/packages/oneclient_app/src/layout/settings_shell.rs @@ -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) -> impl IntoElement { ) } -fn search_results(query: String, mut search: State) -> Vec { - let q = query.to_lowercase(); - let mut matches: Vec = 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) -> Vec { + 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 = scored.into_iter().map(|(_, _, item)| item).collect(); matches.truncate(SEARCH_RESULTS_MAX); if matches.is_empty() { diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index 7931b2d0..9840e5f5 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -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()); diff --git a/packages/oneclient_app/src/view/app/cluster/logs.rs b/packages/oneclient_app/src/view/app/cluster/logs.rs index 887fb50c..a7ac67e0 100644 --- a/packages/oneclient_app/src/view/app/cluster/logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/logs.rs @@ -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 = 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(); diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index b2738311..9fc2fb04 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -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 { + 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 { 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 { let mut cats: Vec = 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) -> 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, - bundles: &[BundleWithUpdateStatus], - ) -> Vec { - bundle_packages( - content, - bundles, - &HashMap::new(), - &PackageMetaMap::new(), - &HashSet::new(), - ContentType::Mod, - ) - } - - fn ids(rows: &[PackageEntry]) -> Vec { - rows.iter().map(|r| r.package_id.clone()).collect() - } - - fn shown(rows: &[PackageEntry], hidden: HiddenFilter) -> Vec { - 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 { - 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 { - 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"]); - } -} diff --git a/packages/oneclient_common/src/lib.rs b/packages/oneclient_common/src/lib.rs index b5cf097a..1fea44f0 100644 --- a/packages/oneclient_common/src/lib.rs +++ b/packages/oneclient_common/src/lib.rs @@ -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}; diff --git a/packages/oneclient_common/src/search.rs b/packages/oneclient_common/src/search.rs new file mode 100644 index 00000000..cae65155 --- /dev/null +++ b/packages/oneclient_common/src/search.rs @@ -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, +} + +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 { + 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) -> Option { + 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 { + 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 { + let hay: Vec = hay.chars().collect(); + let needle: Vec = 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 { + let a: Vec = a.chars().collect(); + let b: Vec = 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 = vec![0; b.len() + 1]; + let mut prev: Vec = (0..=b.len()).collect(); + let mut cur: Vec = 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 { + 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); + } +} diff --git a/packages/oneclient_content/src/packages/provider/curseforge.rs b/packages/oneclient_content/src/packages/provider/curseforge.rs index 3c3d4493..8e358313 100644 --- a/packages/oneclient_content/src/packages/provider/curseforge.rs +++ b/packages/oneclient_content/src/packages/provider/curseforge.rs @@ -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); diff --git a/packages/oneclient_content/src/packages/provider/modrinth.rs b/packages/oneclient_content/src/packages/provider/modrinth.rs index dfe04fa2..675b25ef 100644 --- a/packages/oneclient_content/src/packages/provider/modrinth.rs +++ b/packages/oneclient_content/src/packages/provider/modrinth.rs @@ -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::new(); if let Some(content_type) = filters.content_type { diff --git a/packages/oneclient_content/src/packages/types.rs b/packages/oneclient_content/src/packages/types.rs index 811e9e57..96600674 100644 --- a/packages/oneclient_content/src/packages/types.rs +++ b/packages/oneclient_content/src/packages/types.rs @@ -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, } +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]