/** * KHAELOR * File: src/repository/search.ts * Description: Fuzzy file-name search over the repository map — frecency-boosted ranking for @ mentions and palettes. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { RepositoryMap } from "./map.js"; import type { RecentFilesTracker } from "./recent.js"; /** A ranked fuzzy-search hit (ARCHITECTURE.md `RepositoryIndex.findFiles`). */ export interface RankedFile { path: string; score: number; } export interface FileSearchOptions { /** Default result cap when the caller passes no limit. Default 20. */ maxResults?: number; } // Score tiers — kept ≥ FRECENCY_BOOST_MAX apart across match classes so // frecency reorders results WITHIN a class but never across classes // (exact > prefix > substring > subsequence, always). const TIER_EXACT = 1000; const TIER_BASENAME_PREFIX = 850; const TIER_PATH_PREFIX = 800; const TIER_BASENAME_SUBSTRING = 700; const TIER_PATH_SUBSTRING = 600; const TIER_SUBSEQUENCE = 300; const SUBSEQUENCE_QUALITY_MAX = 150; const SUBSEQUENCE_BASENAME_BONUS = 30; const FRECENCY_BOOST_MAX = 90; /** Characters that start a "word" inside a path for boundary bonuses. */ function isBoundary(previous: string | undefined): boolean { return previous === undefined || previous === "/" || previous === "." || previous === "_" || previous === "-"; } /** * Greedy subsequence match of `query` inside `text`. Returns a quality score * in [0, SUBSEQUENCE_QUALITY_MAX] or undefined when `query` is not a * subsequence. Consecutive runs and word-boundary starts score higher. */ export function subsequenceQuality(text: string, query: string): number | undefined { let quality = 0; let textIndex = 0; let previousMatch = -2; for (const ch of query) { const found = text.indexOf(ch, textIndex); if (found === -1) return undefined; if (found === previousMatch + 1) quality += 8; if (isBoundary(text[found - 1])) quality += 6; previousMatch = found; textIndex = found + 1; } // Denser matches (less spread across the path) are better. const spread = previousMatch - (query.length - 1); quality += Math.max(0, 20 - Math.floor(spread / 4)); return Math.min(SUBSEQUENCE_QUALITY_MAX, quality); } function baseNameOf(rel: string): string { return rel.slice(rel.lastIndexOf("/") + 1); } /** * File-NAME fuzzy search over the repository map, for `@` mentions and the * command palette. Content search stays the grep tool's job — this facade * deliberately does not duplicate it. */ export class FileSearch { private readonly map: RepositoryMap; private readonly recent: RecentFilesTracker | undefined; private readonly maxResults: number; constructor(map: RepositoryMap, recent?: RecentFilesTracker, options: FileSearchOptions = {}) { this.map = map; this.recent = recent; this.maxResults = options.maxResults ?? 20; } /** Base match score for one candidate path, before the frecency boost. */ private matchScore(rel: string, base: string, query: string): number | undefined { if (rel === query || base === query) return TIER_EXACT; if (base.startsWith(query)) return TIER_BASENAME_PREFIX; if (rel.startsWith(query)) return TIER_PATH_PREFIX; if (base.includes(query)) return TIER_BASENAME_SUBSTRING; if (rel.includes(query)) return TIER_PATH_SUBSTRING; const quality = subsequenceQuality(rel, query); if (quality === undefined) return undefined; const inBasename = subsequenceQuality(base, query) !== undefined; return TIER_SUBSEQUENCE + quality + (inBasename ? SUBSEQUENCE_BASENAME_BONUS : 0); } private frecencyBoost(path: string): number { if (this.recent === undefined) return 0; return Math.min(FRECENCY_BOOST_MAX, this.recent.frecency(path) * 5); } /** * Ranked fuzzy matches for `query`. An empty query returns recently * accessed files first, then alphabetical fill up to `limit`. */ async findFiles(query: string, limit?: number): Promise { const cap = limit ?? this.maxResults; const files = await this.map.files(); const q = query.trim().toLowerCase(); if (q.length === 0) { const known = new Set(files.map((f) => f.path)); const results: RankedFile[] = []; const seen = new Set(); for (const r of this.recent?.recent(cap) ?? []) { if (!known.has(r.path) || results.length >= cap) continue; results.push({ path: r.path, score: r.score }); seen.add(r.path); } const rest = files .map((f) => f.path) .filter((p) => !seen.has(p)) .sort((a, b) => a.localeCompare(b)); for (const path of rest) { if (results.length >= cap) break; results.push({ path, score: 0 }); } return results; } const ranked: RankedFile[] = []; for (const file of files) { const rel = file.path.toLowerCase(); const base = baseNameOf(rel); const score = this.matchScore(rel, base, q); if (score === undefined) continue; ranked.push({ path: file.path, score: score + this.frecencyBoost(file.path) }); } ranked.sort( (a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path), ); return ranked.slice(0, cap); } }