11import { Database } from "bun:sqlite" ;
22
3- import Fuse , { type Expression , type IFuseOptions } from "fuse.js " ;
3+ import { FuzzySearch , type ISearchKey } from "@nlptools/distance " ;
44
55import { cacheStorage , memoryStorage } from "../storage" ;
66import { WINGET_SEARCH_INDEX_KEY } from "./constants" ;
@@ -154,9 +154,10 @@ export async function getSearchIndex(): Promise<WinGetSearchEntry[] | null> {
154154 return null ;
155155}
156156
157- // ── Fuse.js configuration ─────────────────────────────
157+ // ── Search configuration ─ ─────────────────────────────
158158
159- const FUSE_KEYS = [
159+ /** Search keys with weights matching fuse.js config */
160+ const SEARCH_KEYS : ISearchKey [ ] = [
160161 { name : "id" , weight : 2 } ,
161162 { name : "name" , weight : 2 } ,
162163 { name : "publisher" , weight : 1 } ,
@@ -168,20 +169,11 @@ const FUSE_KEYS = [
168169 { name : "upgradeCodes" , weight : 1 } ,
169170] ;
170171
171- const FUSE_OPTIONS : IFuseOptions < WinGetSearchEntry > = {
172- keys : FUSE_KEYS ,
173- threshold : 0.4 ,
174- includeScore : true ,
175- shouldSort : true ,
176- useExtendedSearch : true ,
177- } ;
178-
179- // ── Fuse instance creation ────────────────────────────
180-
181- /** Map WinGet PackageMatchField to fuse.js key name */
172+ /** Map WinGet PackageMatchField to WinGetSearchEntry key */
182173const FIELD_TO_KEY : Partial < Record < WinGetPackageMatchField , string > > = {
183174 PackageIdentifier : "id" ,
184175 PackageName : "name" ,
176+ Publisher : "publisher" ,
185177 Moniker : "monikers" ,
186178 Command : "commands" ,
187179 Tag : "tags" ,
@@ -190,114 +182,167 @@ const FIELD_TO_KEY: Partial<Record<WinGetPackageMatchField, string>> = {
190182 UpgradeCode : "upgradeCodes" ,
191183} ;
192184
193- /**
194- * Create a fuse.js instance configured for the given match type.
195- */
196- export function createFuse (
197- index : WinGetSearchEntry [ ] ,
198- matchType ?: WinGetMatchType ,
199- ) : Fuse < WinGetSearchEntry > {
200- const opts : IFuseOptions < WinGetSearchEntry > = { ...FUSE_OPTIONS , keys : FUSE_KEYS } ;
201-
202- if ( matchType === "Exact" ) {
203- opts . threshold = 0 ;
204- } else if (
205- matchType === "CaseInsensitive" ||
206- matchType === "Substring" ||
207- matchType === "StartsWith"
208- ) {
209- opts . threshold = 0 ;
210- opts . ignoreLocation = true ;
211- } else if ( matchType === "Fuzzy" ) {
212- opts . threshold = 0.4 ;
213- } else if ( matchType === "FuzzySubstring" ) {
214- opts . threshold = 0.4 ;
215- opts . ignoreLocation = true ;
216- }
185+ // ── Match helpers ─────────────────────────────────────
217186
218- return new Fuse ( index , opts ) ;
219- }
187+ /** Match a single string value against a keyword with the given match type */
188+ export function matchString ( value : string , keyword : string , matchType ?: WinGetMatchType ) : boolean {
189+ const lv = value . toLowerCase ( ) ;
190+ const lk = keyword . toLowerCase ( ) ;
220191
221- /** Map WinGet MatchType to fuse.js extended search pattern modifier */
222- export function toExtendedPattern ( keyword : string , matchType ?: WinGetMatchType ) : string {
223192 switch ( matchType ) {
224193 case "Exact" :
225- return `'${ keyword } ` ;
194+ return lv === lk ;
195+ case "CaseInsensitive" :
196+ return lv . includes ( lk ) ;
226197 case "StartsWith" :
227- return `^${ keyword } ` ;
198+ return lv . startsWith ( lk ) ;
199+ case "Substring" :
200+ return lv . includes ( lk ) ;
228201 case "Wildcard" :
229- return keyword ;
202+ // Simple glob: treat as case-insensitive substring for now
203+ return lv . includes ( lk ) ;
204+ case "Fuzzy" :
205+ case "FuzzySubstring" :
206+ // Fuzzy matching handled by FuzzySearch; this is used for filters/inclusions
207+ // Fall through to case-insensitive substring as a reasonable default
208+ return lv . includes ( lk ) ;
230209 default :
231- return keyword ;
210+ return lv . includes ( lk ) ;
232211 }
233212}
234213
235214/**
236- * Build a combined fuse.js extended search query.
237- * Uses $and for keyword + inclusions, and ! prefix for filters (NOT).
238- * NormalizedPackageNameAndPublisher maps to $or across name and publisher.
215+ * Check if an entry matches a single filter/inclusion condition.
216+ * For array fields (monikers, tags, commands, etc.), checks if any element matches.
239217 */
240- export function buildSearchQuery (
241- keyword ?: string ,
218+ export function matchesField (
219+ entry : WinGetSearchEntry ,
220+ fieldName : string ,
221+ keyword : string ,
242222 matchType ?: WinGetMatchType ,
243- inclusions ?: WinGetSearchRequestPackageMatchFilter [ ] ,
244- filters ?: WinGetSearchRequestPackageMatchFilter [ ] ,
245- ) : string | Expression {
246- const conditions : ( string | Expression ) [ ] = [ ] ;
247-
248- if ( keyword ) {
249- conditions . push ( toExtendedPattern ( keyword , matchType ) ) ;
223+ ) : boolean {
224+ const value = ( entry as unknown as Record < string , unknown > ) [ fieldName ] ;
225+ if ( typeof value === "string" ) {
226+ return matchString ( value , keyword , matchType ) ;
250227 }
228+ if ( Array . isArray ( value ) ) {
229+ return value . some ( ( v ) => typeof v === "string" && matchString ( v , keyword , matchType ) ) ;
230+ }
231+ return false ;
232+ }
251233
252- if ( inclusions ) {
253- for ( const inc of inclusions ) {
254- if ( ! inc . RequestMatch ?. KeyWord || ! inc . PackageMatchField ) continue ;
234+ /**
235+ * Apply inclusions (AND): entry must match ALL inclusions.
236+ * NormalizedPackageNameAndPublisher maps to name OR publisher.
237+ */
238+ export function matchesInclusions (
239+ entry : WinGetSearchEntry ,
240+ inclusions : WinGetSearchRequestPackageMatchFilter [ ] ,
241+ ) : boolean {
242+ return inclusions . every ( ( inc ) => {
243+ if ( ! inc . RequestMatch ?. KeyWord ) return true ;
244+ const kw = inc . RequestMatch . KeyWord ;
245+ const mt = inc . RequestMatch . MatchType ;
246+
247+ if ( inc . PackageMatchField === "NormalizedPackageNameAndPublisher" ) {
248+ return matchString ( entry . name , kw , mt ) || matchString ( entry . publisher , kw , mt ) ;
249+ }
255250
256- if ( inc . PackageMatchField === "NormalizedPackageNameAndPublisher" ) {
257- const pattern = toExtendedPattern ( inc . RequestMatch . KeyWord , inc . RequestMatch . MatchType ) ;
258- conditions . push ( { $or : [ { name : pattern } , { publisher : pattern } ] } ) ;
259- continue ;
260- }
251+ const key = FIELD_TO_KEY [ inc . PackageMatchField ] ;
252+ if ( ! key ) return true ;
253+ return matchesField ( entry , key , kw , mt ) ;
254+ } ) ;
255+ }
261256
262- const key = FIELD_TO_KEY [ inc . PackageMatchField ] ;
263- if ( ! key ) continue ;
264- conditions . push ( {
265- [ key ] : toExtendedPattern ( inc . RequestMatch . KeyWord , inc . RequestMatch . MatchType ) ,
266- } ) ;
257+ /**
258+ * Apply filters (AND): entry must NOT match ANY filter.
259+ */
260+ export function matchesFilters (
261+ entry : WinGetSearchEntry ,
262+ filters : WinGetSearchRequestPackageMatchFilter [ ] ,
263+ ) : boolean {
264+ return ! filters . some ( ( f ) => {
265+ if ( ! f . RequestMatch ?. KeyWord ) return false ;
266+ const kw = f . RequestMatch . KeyWord ;
267+ const mt = f . RequestMatch . MatchType ;
268+
269+ if ( f . PackageMatchField === "NormalizedPackageNameAndPublisher" ) {
270+ return matchString ( entry . name , kw , mt ) || matchString ( entry . publisher , kw , mt ) ;
267271 }
268- }
269272
270- if ( filters ) {
271- for ( const f of filters ) {
272- if ( ! f . RequestMatch ?. KeyWord || ! f . PackageMatchField ) continue ;
273-
274- if ( f . PackageMatchField === "NormalizedPackageNameAndPublisher" ) {
275- const pattern = toExtendedPattern ( f . RequestMatch . KeyWord , f . RequestMatch . MatchType ) ;
276- conditions . push ( {
277- $or : [
278- { name : `!${ pattern . replace ( / ^ ! / , "" ) } ` } ,
279- { publisher : `!${ pattern . replace ( / ^ ! / , "" ) } ` } ,
280- ] ,
281- } ) ;
282- continue ;
283- }
273+ const key = FIELD_TO_KEY [ f . PackageMatchField ] ;
274+ if ( ! key ) return false ;
275+ return matchesField ( entry , key , kw , mt ) ;
276+ } ) ;
277+ }
278+
279+ // ── FuzzySearch configuration per match type ──────────
284280
285- const key = FIELD_TO_KEY [ f . PackageMatchField ] ;
286- if ( ! key ) continue ;
287- const raw = toExtendedPattern ( f . RequestMatch . KeyWord , f . RequestMatch . MatchType ) ;
288- conditions . push ( { [ key ] : `!${ raw . replace ( / ^ ! / , "" ) } ` } ) ;
281+ /**
282+ * Check if any searchable field of an entry matches the keyword, and return
283+ * a relevance score for sorting. Higher score = more relevant.
284+ */
285+ export function scoreEntryKeyword (
286+ entry : WinGetSearchEntry ,
287+ keyword : string ,
288+ matchType ?: WinGetMatchType ,
289+ ) : number {
290+ const kw = keyword . toLowerCase ( ) ;
291+ const fields = [
292+ entry . id ,
293+ entry . name ,
294+ entry . publisher ,
295+ ...entry . monikers ,
296+ ...entry . tags ,
297+ ...entry . commands ,
298+ ...entry . packageFamilyNames ,
299+ ...entry . productCodes ,
300+ ...entry . upgradeCodes ,
301+ ] ;
302+
303+ let best = 0 ;
304+
305+ for ( let i = 0 ; i < fields . length ; i ++ ) {
306+ const f = fields [ i ] ! . toLowerCase ( ) ;
307+ let matched = false ;
308+ let score = 0 ;
309+
310+ switch ( matchType ) {
311+ case "Exact" :
312+ matched = f === kw ;
313+ score = matched ? 1000 - i : 0 ;
314+ break ;
315+ case "CaseInsensitive" :
316+ // WinGet CaseInsensitive = case-insensitive substring match (not exact)
317+ matched = f . includes ( kw ) ;
318+ score = matched ? 1000 - i + ( kw . length / f . length ) * 100 : 0 ;
319+ break ;
320+ case "StartsWith" :
321+ matched = f . startsWith ( kw ) ;
322+ score = matched ? 1000 - i + ( kw . length / f . length ) * 100 : 0 ;
323+ break ;
324+ case "Substring" :
325+ case "Wildcard" :
326+ case "FuzzySubstring" :
327+ matched = f . includes ( kw ) ;
328+ score = matched ? 1000 - i + ( kw . length / f . length ) * 100 : 0 ;
329+ break ;
330+ default :
331+ matched = f . includes ( kw ) ;
332+ score = matched ? 1000 - i + ( kw . length / f . length ) * 100 : 0 ;
289333 }
334+
335+ if ( score > best ) best = score ;
336+ if ( matched && ( matchType === "Exact" || matchType === "CaseInsensitive" ) ) return best ;
290337 }
291338
292- if ( conditions . length === 0 ) return "" ;
293- if ( conditions . length === 1 ) return conditions [ 0 ] ! ;
294- return { $and : conditions as Expression [ ] } ;
339+ return best ;
295340}
296341
297342// ── Main search function ──────────────────────────────
298343
299344/**
300- * Search packages using fuse.js extended search .
345+ * Search packages using @nlptools/distance FuzzySearch .
301346 * All data comes from the in-memory search index — no DB query needed.
302347 */
303348export function searchPackages ( options : {
@@ -326,9 +371,43 @@ export function searchPackages(options: {
326371 if ( ! keyword && ! inclusions ?. length && ! filters ?. length ) {
327372 matchedEntries = searchIndex ;
328373 } else {
329- const fuse = createFuse ( searchIndex , matchType ) ;
330- const query = buildSearchQuery ( keyword , matchType , inclusions , filters ) ;
331- matchedEntries = fuse . search ( query ) . map ( ( r ) => r . item ) ;
374+ let candidates : WinGetSearchEntry [ ] ;
375+
376+ if ( keyword ) {
377+ const isFuzzy = matchType === "Fuzzy" || matchType === "FuzzySubstring" ;
378+
379+ if ( isFuzzy ) {
380+ // Use FuzzySearch with levenshtein for fuzzy matching
381+ const engine = new FuzzySearch ( searchIndex , {
382+ keys : SEARCH_KEYS ,
383+ algorithm : "levenshtein" ,
384+ threshold : matchType === "Fuzzy" ? 0.15 : 0.1 ,
385+ caseSensitive : false ,
386+ } ) ;
387+ candidates = engine . search ( keyword ) . map ( ( r ) => r . item ) ;
388+ } else {
389+ // Linear scan for exact/prefix/substring — fast and correct
390+ candidates = searchIndex
391+ . map ( ( e ) => ( { entry : e , score : scoreEntryKeyword ( e , keyword , matchType ) } ) )
392+ . filter ( ( e ) => e . score > 0 )
393+ . sort ( ( a , b ) => b . score - a . score )
394+ . map ( ( e ) => e . entry ) ;
395+ }
396+ } else {
397+ candidates = searchIndex ;
398+ }
399+
400+ // Apply inclusions (AND)
401+ if ( inclusions ?. length ) {
402+ candidates = candidates . filter ( ( e ) => matchesInclusions ( e , inclusions ) ) ;
403+ }
404+
405+ // Apply filters (NOT)
406+ if ( filters ?. length ) {
407+ candidates = candidates . filter ( ( e ) => matchesFilters ( e , filters ) ) ;
408+ }
409+
410+ matchedEntries = candidates ;
332411 }
333412
334413 const results : WinGetManifestSearchResponse [ ] = matchedEntries . map ( ( entry ) => ( {
0 commit comments