All files / src/utils fuzzy-search.ts

94.44% Statements 34/36
91.66% Branches 11/12
100% Functions 5/5
94.11% Lines 32/34

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 7641x 6x       6x   6x   20x 20x 20x 20x   20x 45x 45x   45x 10x       35x 14x       35x         10x       35x 32x       35x 24x 24x   11x       35x   35x       10x   10x       20x       6x 5x 5x         10x    
export function fuzzySearch(query: string, items: string[]): string[] {
  Iif (!query) {
    return items;
  }
 
  const lowerCaseQuery = query.toLowerCase();
 
  const rankedItems = items
    .map((item, index) => {
      const lowerCaseItem = item.toLowerCase();
      let score = 0;
      let consecutiveMatches = 0;
      let lastMatchIndex = -1;
 
      for (let i = 0; i < lowerCaseQuery.length; i++) {
        const char = lowerCaseQuery[i];
        const foundIndex = lowerCaseItem.indexOf(char, lastMatchIndex + 1);
 
        if (foundIndex === -1) {
          return null; // Doesn't match
        }
 
        // Bonus if the first query char was matched very early
        if (i === 0) {
          score += Math.max(10, 50 - foundIndex);
        }
 
        // Score bonus for being at the start of a word (separator or camelCase)
        if (
          foundIndex === 0 ||
          ['/', '\\', '_', '-', '.'].includes(lowerCaseItem[foundIndex - 1]) ||
          (/[a-z]/.test(item[foundIndex - 1]) && /[A-Z]/.test(item[foundIndex]))
        ) {
          score += 15;
        }
 
        // Score bonus for matching case
        if (item[foundIndex] === query[i]) {
          score += 5;
        }
 
        // Score bonus for consecutive matches
        if (lastMatchIndex === foundIndex - 1) {
          consecutiveMatches++;
          score += 20 * consecutiveMatches;
        } else {
          consecutiveMatches = 0;
        }
 
        // Penalty for distance between matches
        score -= foundIndex - (lastMatchIndex + 1);
 
        lastMatchIndex = foundIndex;
      }
 
      // Add a small penalty based on item length to favor shorter matches
      score -= item.length * 0.1;
 
      return { item, score, index };
    })
    .filter(
      (result): result is { item: string; score: number; index: number } =>
        result !== null,
    );
 
  // Sort by score descending, then by original index ascending for stability
  rankedItems.sort((a, b) => {
    if (a.score !== b.score) {
      return b.score - a.score;
    }
    return a.index - b.index;
  });
 
  return rankedItems.map((result) => result.item);
}