40 lines · 1.6 KB
export type FuzzyMatch = { path: string; score: number; positions: number[] };

const CONSECUTIVE_BONUS = 5;
const BOUNDARY_BONUS = 8;
const BASENAME_BONUS = 2;
const GAP_PENALTY = 1;
const BOUNDARY = /[/_\-. ]/;

/** Subsequence match favouring consecutive runs, segment starts and the file name; null when `query` is not a subsequence. */
export function fuzzyScore(query: string, path: string): FuzzyMatch | null {
  const needle = query.toLowerCase().replace(/\s+/g, "");
  if (!needle) return { path, score: 0, positions: [] };
  const haystack = path.toLowerCase();
  const basenameStart = path.lastIndexOf("/") + 1;
  const positions: number[] = [];
  let score = 0;
  let from = 0;
  for (const char of needle) {
    const index = haystack.indexOf(char, from);
    if (index < 0) return null;
    const previous = positions[positions.length - 1];
    if (previous !== undefined && index === previous + 1) score += CONSECUTIVE_BONUS;
    else if (previous !== undefined) score -= Math.min(index - previous, 10) * GAP_PENALTY;
    if (index === 0 || BOUNDARY.test(path[index - 1] ?? "")) score += BOUNDARY_BONUS;
    if (index >= basenameStart) score += BASENAME_BONUS;
    positions.push(index);
    from = index + 1;
  }
  return { path, score: score - path.length / 100, positions };
}

export function fuzzyFilter(query: string, paths: string[], limit: number): FuzzyMatch[] {
  const matches: FuzzyMatch[] = [];
  for (const path of paths) {
    const match = fuzzyScore(query, path);
    if (match) matches.push(match);
  }
  return matches.sort((left, right) => right.score - left.score).slice(0, limit);
}