@@ -62,6 +62,44 @@ function normalizeQuery(input: string): string {
6262 . toLowerCase ( ) ;
6363}
6464
65+ /**
66+ * Score a fuzzy subsequence match of `query` against `value`.
67+ * Returns a numeric penalty (lower = better) or `null` if the query
68+ * characters do not appear as a subsequence in order.
69+ */
70+ function scoreSubsequenceMatch ( query : string , value : string ) : number | null {
71+ let queryIndex = 0 ;
72+ let firstMatchIndex = - 1 ;
73+ let lastMatchIndex = - 1 ;
74+ let gapPenalty = 0 ;
75+ let prevMatchIndex = - 1 ;
76+
77+ for ( let i = 0 ; i < value . length && queryIndex < query . length ; i ++ ) {
78+ if ( value [ i ] === query [ queryIndex ] ) {
79+ if ( firstMatchIndex === - 1 ) {
80+ firstMatchIndex = i ;
81+ }
82+ if ( prevMatchIndex !== - 1 ) {
83+ const gap = i - prevMatchIndex - 1 ;
84+ if ( gap > 0 ) {
85+ gapPenalty += gap ;
86+ }
87+ }
88+ prevMatchIndex = i ;
89+ lastMatchIndex = i ;
90+ queryIndex ++ ;
91+ }
92+ }
93+
94+ if ( queryIndex < query . length ) {
95+ return null ;
96+ }
97+
98+ const spanPenalty = lastMatchIndex - firstMatchIndex - query . length + 1 ;
99+ const lengthPenalty = Math . min ( value . length , 64 ) ;
100+ return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty ;
101+ }
102+
65103function scoreEntry ( entry : ProjectEntry , query : string ) : number {
66104 if ( ! query ) {
67105 return entry . kind === "directory" ? 0 : 1 ;
@@ -75,7 +113,16 @@ function scoreEntry(entry: ProjectEntry, query: string): number {
75113 if ( normalizedName . startsWith ( query ) ) return 2 ;
76114 if ( normalizedPath . startsWith ( query ) ) return 3 ;
77115 if ( normalizedPath . includes ( `/${ query } ` ) ) return 4 ;
78- return 5 ;
116+ if ( normalizedName . includes ( query ) ) return 5 ;
117+ if ( normalizedPath . includes ( query ) ) return 6 ;
118+
119+ const nameFuzzy = scoreSubsequenceMatch ( query , normalizedName ) ;
120+ if ( nameFuzzy !== null ) return 100 + nameFuzzy ;
121+
122+ const pathFuzzy = scoreSubsequenceMatch ( query , normalizedPath ) ;
123+ if ( pathFuzzy !== null ) return 200 + pathFuzzy ;
124+
125+ return Infinity ;
79126}
80127
81128function isPathInIgnoredDirectory ( relativePath : string ) : boolean {
@@ -419,23 +466,80 @@ export function clearWorkspaceIndexCache(cwd: string): void {
419466 inFlightWorkspaceIndexBuilds . delete ( cwd ) ;
420467}
421468
469+ function compareRankedEntries (
470+ left : { entry : ProjectEntry ; score : number } ,
471+ right : { entry : ProjectEntry ; score : number } ,
472+ ) : number {
473+ return left . score - right . score || left . entry . path . localeCompare ( right . entry . path ) ;
474+ }
475+
476+ function findInsertionIndex (
477+ ranked : Array < { entry : ProjectEntry ; score : number } > ,
478+ candidate : { entry : ProjectEntry ; score : number } ,
479+ ) : number {
480+ let lo = 0 ;
481+ let hi = ranked . length ;
482+ while ( lo < hi ) {
483+ const mid = ( lo + hi ) >>> 1 ;
484+ if ( compareRankedEntries ( ranked [ mid ] ! , candidate ) <= 0 ) {
485+ lo = mid + 1 ;
486+ } else {
487+ hi = mid ;
488+ }
489+ }
490+ return lo ;
491+ }
492+
493+ function insertRankedEntry (
494+ ranked : Array < { entry : ProjectEntry ; score : number } > ,
495+ entry : ProjectEntry ,
496+ score : number ,
497+ limit : number ,
498+ ) : void {
499+ if ( limit <= 0 ) {
500+ return ;
501+ }
502+ const candidate = { entry, score } ;
503+ if ( ranked . length >= limit && compareRankedEntries ( candidate , ranked [ ranked . length - 1 ] ! ) >= 0 ) {
504+ return ;
505+ }
506+ const index = findInsertionIndex ( ranked , candidate ) ;
507+ ranked . splice ( index , 0 , candidate ) ;
508+ if ( ranked . length > limit ) {
509+ ranked . pop ( ) ;
510+ }
511+ }
512+
422513export async function searchWorkspaceEntries (
423514 input : ProjectSearchEntriesInput ,
424515) : Promise < ProjectSearchEntriesResult > {
425516 const index = await getWorkspaceIndex ( input . cwd ) ;
426517 const normalizedQuery = normalizeQuery ( input . query ) ;
427- const candidates = normalizedQuery
428- ? index . entries . filter ( ( entry ) => entry . path . toLowerCase ( ) . includes ( normalizedQuery ) )
429- : index . entries ;
430-
431- const ranked = candidates . toSorted ( ( left , right ) => {
432- const scoreDelta = scoreEntry ( left , normalizedQuery ) - scoreEntry ( right , normalizedQuery ) ;
433- if ( scoreDelta !== 0 ) return scoreDelta ;
434- return left . path . localeCompare ( right . path ) ;
435- } ) ;
518+
519+ if ( ! normalizedQuery ) {
520+ const ranked = index . entries . toSorted ( ( left , right ) => {
521+ const scoreDelta = scoreEntry ( left , normalizedQuery ) - scoreEntry ( right , normalizedQuery ) ;
522+ if ( scoreDelta !== 0 ) return scoreDelta ;
523+ return left . path . localeCompare ( right . path ) ;
524+ } ) ;
525+ return {
526+ entries : ranked . slice ( 0 , input . limit ) ,
527+ truncated : index . truncated || ranked . length > input . limit ,
528+ } ;
529+ }
530+
531+ const ranked : Array < { entry : ProjectEntry ; score : number } > = [ ] ;
532+ let matchedEntryCount = 0 ;
533+
534+ for ( const entry of index . entries ) {
535+ const score = scoreEntry ( entry , normalizedQuery ) ;
536+ if ( ! Number . isFinite ( score ) ) continue ;
537+ matchedEntryCount ++ ;
538+ insertRankedEntry ( ranked , entry , score , input . limit ) ;
539+ }
436540
437541 return {
438- entries : ranked . slice ( 0 , input . limit ) ,
439- truncated : index . truncated || ranked . length > input . limit ,
542+ entries : ranked . map ( ( item ) => item . entry ) ,
543+ truncated : index . truncated || matchedEntryCount > input . limit ,
440544 } ;
441545}
0 commit comments