From 3c8cc3a93e367a005e50ccc21a93c22b3455aaf6 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 26 Dec 2025 01:18:29 +0200 Subject: [PATCH] feat: add fuzzy matching to file search --- .../desktop/src-tauri/src/commands/files.rs | 139 +++++++++++++++--- packages/vscode/src/bridge.ts | 133 ++++++++++++++--- packages/web/server/index.js | 134 ++++++++++++++--- 3 files changed, 355 insertions(+), 51 deletions(-) diff --git a/packages/desktop/src-tauri/src/commands/files.rs b/packages/desktop/src-tauri/src/commands/files.rs index ca1e143d..f4afbb4e 100644 --- a/packages/desktop/src-tauri/src/commands/files.rs +++ b/packages/desktop/src-tauri/src/commands/files.rs @@ -211,6 +211,11 @@ pub async fn list_directory( }) } +struct ScoredFileHit { + hit: FileSearchHit, + score: i32, +} + #[tauri::command] pub async fn search_files( directory: Option, @@ -227,14 +232,17 @@ pub async fn search_files( let normalized_query = query.unwrap_or_default().trim().to_lowercase(); let match_all = normalized_query.is_empty(); - let mut files = Vec::new(); + // Collect more candidates for fuzzy matching, then sort and trim + let collect_limit = if match_all { limit } else { (limit * 3).max(200) }; + + let mut candidates: Vec = Vec::new(); let mut queue = VecDeque::new(); let mut visited = HashSet::new(); queue.push_back(resolved_root.clone()); visited.insert(resolved_root.clone()); - while !queue.is_empty() && files.len() < limit { + while !queue.is_empty() && candidates.len() < collect_limit { for _ in 0..FILE_SEARCH_MAX_CONCURRENCY { let Some(dir) = queue.pop_front() else { break; @@ -261,7 +269,7 @@ pub async fn search_files( if should_skip_directory(&name_str) { continue; } - if visited.insert(entry_path.clone()) && files.len() < limit { + if visited.insert(entry_path.clone()) && candidates.len() < collect_limit { queue.push_back(entry_path); } continue; @@ -272,39 +280,59 @@ pub async fn search_files( } let relative_path = relative_path(&resolved_root, &entry_path); - if !match_all { - let lowercase_name = name_str.to_lowercase(); - let lowercase_path = relative_path.to_lowercase(); - if !lowercase_name.contains(&normalized_query) - && !lowercase_path.contains(&normalized_query) - { - continue; - } - } - let extension = entry_path .extension() .and_then(|ext| ext.to_str()) .map(|ext| ext.to_lowercase()); - files.push(FileSearchHit { + let hit = FileSearchHit { name: name_str.to_string(), path: normalize_path(&entry_path), relative_path: relative_path.replace('\\', "/"), extension, - }); + }; - if files.len() >= limit { + if match_all { + candidates.push(ScoredFileHit { hit, score: 0 }); + } else { + // Try fuzzy match against relative path (includes filename) + if let Some(score) = fuzzy_match_score(&normalized_query, &relative_path) { + candidates.push(ScoredFileHit { hit, score }); + } + } + + if candidates.len() >= collect_limit { break; } } - if files.len() >= limit { + if candidates.len() >= collect_limit { break; } } } + // Sort by score descending, then by path length, then alphabetically + if !match_all { + candidates.sort_by(|a, b| { + match b.score.cmp(&a.score) { + std::cmp::Ordering::Equal => { + match a.hit.relative_path.len().cmp(&b.hit.relative_path.len()) { + std::cmp::Ordering::Equal => a.hit.relative_path.cmp(&b.hit.relative_path), + other => other, + } + } + other => other, + } + }); + } + + let files: Vec = candidates + .into_iter() + .take(limit) + .map(|scored| scored.hit) + .collect(); + Ok(SearchFilesResponse { root: normalize_path(&resolved_root), count: files.len(), @@ -432,6 +460,83 @@ fn should_skip_directory(name: &str) -> bool { .any(|dir| dir.eq_ignore_ascii_case(name)) } +/// Fuzzy match scoring function. +/// Returns Some(score) if the query fuzzy-matches the candidate, None otherwise. +/// Higher scores indicate better matches. +fn fuzzy_match_score(query: &str, candidate: &str) -> Option { + if query.is_empty() { + return Some(0); + } + + let q: Vec = query.to_lowercase().chars().collect(); + let c: Vec = candidate.to_lowercase().chars().collect(); + let c_str = candidate.to_lowercase(); + + // Fast path: exact substring match gets high score + if c_str.contains(query) { + if let Some(idx) = c_str.find(query) { + let mut bonus: i32 = 0; + if idx == 0 { + bonus = 20; + } else if let Some(prev) = c.get(idx.saturating_sub(1)) { + if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' ' { + bonus = 15; + } + } + return Some(100 + bonus - (idx.min(20) as i32) - (c.len() as i32 / 5)); + } + } + + // Fuzzy match: all query chars must appear in order + let mut score: i32 = 0; + let mut last_index: i32 = -1; + let mut consecutive: i32 = 0; + + for ch in &q { + if *ch == ' ' { + continue; + } + + let search_start = if last_index < 0 { 0 } else { (last_index + 1) as usize }; + let idx = c[search_start..].iter().position(|&c_char| c_char == *ch); + + match idx { + None => return None, // No match + Some(relative_idx) => { + let idx = search_start + relative_idx; + let gap = idx as i32 - last_index - 1; + + if gap == 0 { + consecutive += 1; + } else { + consecutive = 0; + } + + score += 10; + score += (18 - idx as i32).max(0); // Prefer matches near start + score -= gap.min(10); // Penalize gaps + + // Bonus for word boundary matches + if idx == 0 { + score += 12; + } else if let Some(prev) = c.get(idx - 1) { + if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' ' { + score += 10; + } + } + + score += if consecutive > 0 { 12 } else { 0 }; // Bonus for consecutive matches + last_index = idx as i32; + } + } + } + + // Prefer shorter paths + score += (24 - c.len() as i32 / 3).max(0); + + Some(score) +} + fn normalize_path(path: &Path) -> String { path.to_string_lossy().replace('\\', "/") } diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index ddc40c51..8ae483de 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -140,6 +140,77 @@ const shouldSkipSearchDirectory = (name: string) => { return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase()); }; +/** + * Fuzzy match scoring function. + * Returns a score > 0 if the query fuzzy-matches the candidate, null otherwise. + * Higher scores indicate better matches. + */ +const fuzzyMatchScore = (query: string, candidate: string): number | null => { + if (!query) return 0; + + const q = query.toLowerCase(); + const c = candidate.toLowerCase(); + + // Fast path: exact substring match gets high score + if (c.includes(q)) { + const idx = c.indexOf(q); + let bonus = 0; + if (idx === 0) { + bonus = 20; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + bonus = 15; + } + } + return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5); + } + + // Fuzzy match: all query chars must appear in order + let score = 0; + let lastIndex = -1; + let consecutive = 0; + + for (let i = 0; i < q.length; i++) { + const ch = q[i]; + if (!ch || ch === ' ') continue; + + const idx = c.indexOf(ch, lastIndex + 1); + if (idx === -1) { + return null; // No match + } + + const gap = idx - lastIndex - 1; + if (gap === 0) { + consecutive++; + } else { + consecutive = 0; + } + + score += 10; + score += Math.max(0, 18 - idx); // Prefer matches near start + score -= Math.min(gap, 10); // Penalize gaps + + // Bonus for word boundary matches + if (idx === 0) { + score += 12; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + score += 10; + } + } + + score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches + lastIndex = idx; + } + + // Prefer shorter paths + score += Math.max(0, 24 - Math.floor(c.length / 3)); + + return score; +}; + const searchFilesystemFiles = async (rootPath: string, query: string, limit: number) => { const normalizedQuery = (query || '').trim().toLowerCase(); const matchAll = normalizedQuery.length === 0; @@ -147,10 +218,12 @@ const searchFilesystemFiles = async (rootPath: string, query: string, limit: num const rootUri = vscode.Uri.file(rootPath); const queue: vscode.Uri[] = [rootUri]; const visited = new Set([normalizeFsPath(rootUri.fsPath)]); - const results: Array<{ name: string; path: string; relativePath: string; extension?: string }> = []; + // Collect more candidates for fuzzy matching, then sort and trim + const collectLimit = matchAll ? limit : Math.max(limit * 3, 200); + const candidates: Array<{ name: string; path: string; relativePath: string; extension?: string; score: number }> = []; const MAX_CONCURRENCY = 5; - while (queue.length > 0 && results.length < limit) { + while (queue.length > 0 && candidates.length < collectLimit) { const batch = queue.splice(0, MAX_CONCURRENCY); const dirLists = await Promise.all( batch.map((dir) => Promise.resolve(vscode.workspace.fs.readDirectory(dir)).catch(() => [] as [string, vscode.FileType][])) @@ -184,34 +257,60 @@ const searchFilesystemFiles = async (rootPath: string, query: string, limit: num } const relativePath = normalizeFsPath(path.relative(rootPath, absolute) || path.basename(absolute)); - if (!matchAll) { - const lowercaseName = entryName.toLowerCase(); - const lowercasePath = relativePath.toLowerCase(); - if (!lowercaseName.includes(normalizedQuery) && !lowercasePath.includes(normalizedQuery)) { - continue; + const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined; + + if (matchAll) { + candidates.push({ + name: entryName, + path: absolute, + relativePath, + extension, + score: 0, + }); + } else { + // Try fuzzy match against relative path (includes filename) + const score = fuzzyMatchScore(normalizedQuery, relativePath); + if (score !== null) { + candidates.push({ + name: entryName, + path: absolute, + relativePath, + extension, + score, + }); } } - results.push({ - name: entryName, - path: absolute, - relativePath, - extension: entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined, - }); - - if (results.length >= limit) { + if (candidates.length >= collectLimit) { queue.length = 0; break; } } - if (results.length >= limit) { + if (candidates.length >= collectLimit) { break; } } } - return results; + // Sort by score descending, then by path length, then alphabetically + if (!matchAll) { + candidates.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (a.relativePath.length !== b.relativePath.length) { + return a.relativePath.length - b.relativePath.length; + } + return a.relativePath.localeCompare(b.relativePath); + }); + } + + // Return top results without the score field + return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({ + name, + path: filePath, + relativePath, + extension, + })); }; const searchDirectory = async (directory: string, query: string, limit = 60) => { diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 0cbc844c..08484718 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -81,15 +81,89 @@ const listDirectoryEntries = async (dirPath) => { } }; +/** + * Fuzzy match scoring function. + * Returns a score > 0 if the query fuzzy-matches the candidate, null otherwise. + * Higher scores indicate better matches. + */ +const fuzzyMatchScore = (query, candidate) => { + if (!query) return 0; + + const q = query.toLowerCase(); + const c = candidate.toLowerCase(); + + // Fast path: exact substring match gets high score + if (c.includes(q)) { + const idx = c.indexOf(q); + // Bonus for match at start or after word boundary + let bonus = 0; + if (idx === 0) { + bonus = 20; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + bonus = 15; + } + } + return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5); + } + + // Fuzzy match: all query chars must appear in order + let score = 0; + let lastIndex = -1; + let consecutive = 0; + + for (let i = 0; i < q.length; i++) { + const ch = q[i]; + if (!ch || ch === ' ') continue; + + const idx = c.indexOf(ch, lastIndex + 1); + if (idx === -1) { + return null; // No match + } + + const gap = idx - lastIndex - 1; + if (gap === 0) { + consecutive++; + } else { + consecutive = 0; + } + + score += 10; + score += Math.max(0, 18 - idx); // Prefer matches near start + score -= Math.min(gap, 10); // Penalize gaps + + // Bonus for word boundary matches + if (idx === 0) { + score += 12; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + score += 10; + } + } + + score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches + lastIndex = idx; + } + + // Prefer shorter paths + score += Math.max(0, 24 - Math.floor(c.length / 3)); + + return score; +}; + const searchFilesystemFiles = async (rootPath, options) => { const { limit, query } = options; const normalizedQuery = query.trim().toLowerCase(); const matchAll = normalizedQuery.length === 0; const queue = [rootPath]; const visited = new Set([rootPath]); - const results = []; + // Collect more candidates for fuzzy matching, then sort and trim + const collectLimit = matchAll ? limit : Math.max(limit * 3, 200); + const candidates = []; - while (queue.length > 0 && results.length < limit) { + while (queue.length > 0 && candidates.length < collectLimit) { const batch = queue.splice(0, FILE_SEARCH_MAX_CONCURRENCY); const dirLists = await Promise.all(batch.map((dir) => listDirectoryEntries(dir))); @@ -121,34 +195,60 @@ const searchFilesystemFiles = async (rootPath, options) => { } const relativePath = normalizeRelativeSearchPath(rootPath, entryPath); - if (!matchAll) { - const lowercaseName = entryName.toLowerCase(); - const lowercasePath = relativePath.toLowerCase(); - if (!lowercaseName.includes(normalizedQuery) && !lowercasePath.includes(normalizedQuery)) { - continue; + const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined; + + if (matchAll) { + candidates.push({ + name: entryName, + path: entryPath, + relativePath, + extension, + score: 0 + }); + } else { + // Try fuzzy match against relative path (includes filename) + const score = fuzzyMatchScore(normalizedQuery, relativePath); + if (score !== null) { + candidates.push({ + name: entryName, + path: entryPath, + relativePath, + extension, + score + }); } } - results.push({ - name: entryName, - path: entryPath, - relativePath, - extension: entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined - }); - - if (results.length >= limit) { + if (candidates.length >= collectLimit) { queue.length = 0; break; } } - if (results.length >= limit) { + if (candidates.length >= collectLimit) { break; } } } - return results; + // Sort by score descending, then by path length, then alphabetically + if (!matchAll) { + candidates.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (a.relativePath.length !== b.relativePath.length) { + return a.relativePath.length - b.relativePath.length; + } + return a.relativePath.localeCompare(b.relativePath); + }); + } + + // Return top results without the score field + return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({ + name, + path: filePath, + relativePath, + extension + })); }; const createTimeoutSignal = (timeoutMs) => {