/** * Fuzzy matching helpers for finding similar strings * * Uses Levenshtein distance to find the closest matches when exact matches * aren't found. Useful for providing "did you mean?" suggestions when users * request non-existent tools, prompts, or resources. * * @module @wavespec/kit/discovery/fuzzy * @example * ```typescript * import { findClosestMatches } from '@wavespec/kit/discovery'; * * const tools = ['list_files', 'read_file', 'write_file', 'delete_file']; * * // User typed "list_fil" instead of "list_files" * const matches = findClosestMatches('list_fil', tools); * // Returns: [ * // { value: 'list_files', distance: 1, similarity: 0.89 } * // ] * * // User typed something that doesn't exist * const matches2 = findClosestMatches('cat', tools); * // Returns: [] (nothing similar enough with default 0.4 threshold) * * // Use stricter matching for very similar names * const matches3 = findClosestMatches('reead_file', tools, { * minSimilarity: 0.5 // Only matches with 50%+ similarity * }); * ``` */ import { distance } from 'fastest-levenshtein'; import type { FuzzyMatchOptions, FuzzyMatchResult } from './types.js'; /** * Find the closest matching strings using Levenshtein distance * * Compares the input string against a list of candidates and returns * the closest matches sorted by similarity. Uses the Levenshtein distance * algorithm to calculate how many single-character edits (insertions, * deletions, substitutions) are needed to transform input into candidate. * * The similarity score is calculated as: `1 - (distance / max_length)` * where max_length is the length of the longer string. * * @param query - The search term (what user typed, e.g., "list_fil") * @param candidates - Array of possible values (e.g., available tool names) * @param options - Matching options * @returns Array of matches sorted by similarity (best first), then by distance * * @throws Nothing - Returns empty array if inputs are invalid * * @example * ```typescript * // Find closest tool name * const tools = ['list_files', 'read_file', 'write_file']; * const matches = findClosestMatches('list_fil', tools); * // Returns: [{ value: 'list_files', distance: 1, similarity: 0.89 }] * * // Case-insensitive matching (default) * const matches2 = findClosestMatches('LIST_FIL', tools); * // Returns: [{ value: 'list_files', distance: 1, similarity: 0.89 }] * * // Case-sensitive matching * const matches3 = findClosestMatches('LIST_FIL', tools, { caseSensitive: true }); * // Returns: [] (too different with case changes) * * // Get more results * const matches4 = findClosestMatches('fil', tools, { maxResults: 5 }); * // Returns: all matching candidates * * // More lenient matching * const matches5 = findClosestMatches('ressource', tools, { * minSimilarity: 0.3 // Allow more typos * }); * ``` */ export function findClosestMatches( query: string, candidates: string[], options?: FuzzyMatchOptions, ): FuzzyMatchResult[] { // Default options const maxResults = options?.maxResults ?? 3; const minSimilarity = options?.minSimilarity ?? 0.4; const caseSensitive = options?.caseSensitive ?? false; // Handle empty inputs if (!query || candidates.length === 0) { return []; } // Normalize query if case-insensitive const normalizedQuery = caseSensitive ? query : query.toLowerCase(); // Calculate distance for each candidate const results: FuzzyMatchResult[] = candidates .map((candidate) => { const normalizedCandidate = caseSensitive ? candidate : candidate.toLowerCase(); const dist = distance(normalizedQuery, normalizedCandidate); // Calculate similarity score (0-1, where 1 is exact match) // Formula: 1 - (distance / max_length) const maxLen = Math.max( normalizedQuery.length, normalizedCandidate.length, ); const similarity = maxLen > 0 ? 1 - dist / maxLen : 1; return { value: candidate, // Original case distance: dist, similarity, }; }) // Filter by minSimilarity .filter((result) => result.similarity >= minSimilarity) // Sort by similarity (desc) then by distance (asc) .sort((a, b) => { // Use small epsilon for float comparison to avoid precision issues const similarityDiff = b.similarity - a.similarity; if (Math.abs(similarityDiff) < 0.001) { // Tie-break by distance when similarities are equal return a.distance - b.distance; } return similarityDiff; }) // Return top N results .slice(0, maxResults); return results; }