From 0c0a684fe38706fdb039c0105732b9ad00bf89aa Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Wed, 5 Nov 2025 15:32:33 -0500 Subject: [PATCH 1/4] feat: shortest distance plugin helpers --- package-lock.json | 10 + package.json | 1 + .../__snapshots__/server.helpers.test.ts.snap | 274 ++++++++++++++++++ src/__tests__/server.helpers.test.ts | 180 +++++++++++- src/server.helpers.ts | 130 ++++++++- 5 files changed, 593 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6f4dd668..1eef5f74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.19.1", + "fastest-levenshtein": "1.0.16", "zod": "3.25.76" }, "bin": { @@ -6014,6 +6015,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", diff --git a/package.json b/package.json index aca326c4..5f47a80d 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.19.1", + "fastest-levenshtein": "1.0.16", "zod": "3.25.76" }, "devDependencies": { diff --git a/src/__tests__/__snapshots__/server.helpers.test.ts.snap b/src/__tests__/__snapshots__/server.helpers.test.ts.snap index eb6018df..83b19b5d 100644 --- a/src/__tests__/__snapshots__/server.helpers.test.ts.snap +++ b/src/__tests__/__snapshots__/server.helpers.test.ts.snap @@ -1,5 +1,279 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`findClosest should attempt to find a closest match, empty haystack 1`] = ` +{ + "match": null, + "query": "Button", +} +`; + +exports[`findClosest should attempt to find a closest match, empty needle 1`] = ` +{ + "match": "Badge", + "query": "", +} +`; + +exports[`findClosest should attempt to find a closest match, exact match 1`] = ` +{ + "match": "Alert", + "query": "Alert", +} +`; + +exports[`findClosest should attempt to find a closest match, match spacing 1`] = ` +{ + "match": "dolor sit", + "query": "dolor sit", +} +`; + +exports[`findClosest should attempt to find a closest match, multiple matches 1`] = ` +{ + "match": "Badge", + "query": "badge", +} +`; + +exports[`findClosest should attempt to find a closest match, multiple matches with case insensitive search 1`] = ` +{ + "match": "Badge", + "query": "BADGE", +} +`; + +exports[`findClosest should attempt to find a closest match, non-existent needle 1`] = ` +{ + "match": "Alert", + "query": "lorem", +} +`; + +exports[`findClosest should attempt to find a closest match, non-existent needle with case insensitive search 1`] = ` +{ + "match": "Alert", + "query": "LOREM", +} +`; + +exports[`findClosest should attempt to find a closest match, partial query 1`] = ` +{ + "match": "Button", + "query": "but", +} +`; + +exports[`findClosest should attempt to find a closest match, typo 1`] = ` +{ + "match": "Button", + "query": "buton", +} +`; + +exports[`fuzzySearch should fuzzy match, contains match multiple 1`] = ` +[ + { + "distance": 6, + "item": "AlertGroup", + "matchType": "contains", + }, + { + "distance": 6, + "item": "BadgeGroup", + "matchType": "contains", + }, + { + "distance": 7, + "item": "ButtonGroup", + "matchType": "contains", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, empty items 1`] = `[]`; + +exports[`fuzzySearch should fuzzy match, empty query 1`] = `[]`; + +exports[`fuzzySearch should fuzzy match, empty query extended distance 1`] = ` +[ + { + "distance": 4, + "item": "Card", + "matchType": "prefix", + }, + { + "distance": 5, + "item": "Alert", + "matchType": "prefix", + }, + { + "distance": 5, + "item": "Badge", + "matchType": "prefix", + }, + { + "distance": 6, + "item": "Button", + "matchType": "prefix", + }, + { + "distance": 10, + "item": "AlertGroup", + "matchType": "prefix", + }, + { + "distance": 10, + "item": "BadgeGroup", + "matchType": "prefix", + }, + { + "distance": 10, + "item": "CardHeader", + "matchType": "prefix", + }, + { + "distance": 11, + "item": "ButtonGroup", + "matchType": "prefix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, exact match 1`] = ` +[ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, exact match case-insensitive 1`] = ` +[ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, fuzzy match within distance 1`] = ` +[ + { + "distance": 5, + "item": "Badge", + "matchType": "fuzzy", + }, + { + "distance": 6, + "item": "Alert", + "matchType": "fuzzy", + }, + { + "distance": 6, + "item": "Card", + "matchType": "fuzzy", + }, + { + "distance": 8, + "item": "AlertGroup", + "matchType": "fuzzy", + }, + { + "distance": 8, + "item": "BadgeGroup", + "matchType": "fuzzy", + }, + { + "distance": 10, + "item": "CardHeader", + "matchType": "fuzzy", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, match within max results 1`] = ` +[ + { + "distance": 3, + "item": "Card", + "matchType": "contains", + }, + { + "distance": 4, + "item": "Alert", + "matchType": "prefix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, match within restricted distance 1`] = ` +[ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, multiple words 1`] = ` +[ + { + "distance": 10, + "item": "Lorem Ipsum Dolor Sit", + "matchType": "contains", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, prefix match 1`] = ` +[ + { + "distance": 3, + "item": "Button", + "matchType": "prefix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, prefix match multiple 1`] = ` +[ + { + "distance": 2, + "item": "Button", + "matchType": "prefix", + }, + { + "distance": 7, + "item": "ButtonGroup", + "matchType": "prefix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, single item 1`] = ` +[ + { + "distance": 0, + "item": "BUTTON", + "matchType": "exact", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, trimmed query 1`] = ` +[ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, +] +`; + exports[`generateHash should minimally generate a consistent hash: hash, object and primitive values 1`] = ` { "valueArray": "0e8e2b3096e6aee20e3cd10b7788ce3d72502061", diff --git a/src/__tests__/server.helpers.test.ts b/src/__tests__/server.helpers.test.ts index 8451ee07..c0480d2e 100644 --- a/src/__tests__/server.helpers.test.ts +++ b/src/__tests__/server.helpers.test.ts @@ -1,4 +1,4 @@ -import { generateHash, isPromise } from '../server.helpers'; +import { generateHash, isPromise, fuzzySearch, findClosest } from '../server.helpers'; describe('generateHash', () => { it('should minimally generate a consistent hash', () => { @@ -48,3 +48,181 @@ describe('isPromise', () => { expect(isPromise(func)).toBe(value); }); }); + +describe('findClosest', () => { + const components = ['Button', 'ButtonGroup', 'Badge', 'BadgeGroup', 'Alert', 'AlertGroup']; + + it.each([ + { + description: 'empty haystack', + query: 'Button', + items: [] + }, + { + description: 'empty needle', + query: '', + items: components + }, + { + description: 'non-existent needle', + query: 'lorem', + items: components + }, + { + description: 'non-existent needle with case insensitive search', + query: 'LOREM', + items: components + }, + { + description: 'exact match', + query: 'Alert', + items: components + }, + { + description: 'partial query', + query: 'but', + items: components + }, + { + description: 'typo', + query: 'buton', + items: components + }, + { + description: 'multiple matches', + query: 'badge', + items: components + }, + { + description: 'multiple matches with case insensitive search', + query: 'BADGE', + items: components + }, + { + description: 'match spacing', + query: 'dolor sit', + items: ['sit', 'dolor', 'dolor sit'] + } + ])('should attempt to find a closest match, $description', ({ query, items }) => { + expect({ + query, + match: findClosest(query, items) + }).toMatchSnapshot(); + }); +}); + +describe('fuzzySearch', () => { + const components = ['Button', 'ButtonGroup', 'Badge', 'BadgeGroup', 'Alert', 'AlertGroup', 'Card', 'CardHeader']; + + it.each([ + { + description: 'exact match', + query: 'Button', + items: components, + options: undefined + }, + { + description: 'exact match case-insensitive', + query: 'button', + items: components, + options: undefined + }, + { + description: 'prefix match', + query: 'but', + items: components, + options: undefined + }, + { + description: 'prefix match multiple', + query: 'butt', + items: components, + options: { + maxDistance: 10 + } + }, + { + description: 'contains match multiple', + query: 'roup', + items: components, + options: { + maxDistance: 10 + } + }, + { + description: 'fuzzy match within distance', + query: 'button', + items: components, + options: { + maxDistance: 10, + isExactMatch: false, + isPrefixMatch: false, + isContainsMatch: false, + isFuzzyMatch: true + } + }, + { + description: 'match within max results', + query: 'a', + items: components, + options: { + maxDistance: 10, + maxResults: 2, + isFuzzyMatch: true + } + }, + { + description: 'match within restricted distance', + query: 'button', + items: components, + options: { + maxDistance: 1 + } + }, + { + description: 'empty query', + query: '', + items: components, + options: { + isFuzzyMatch: true + } + }, + { + description: 'empty query extended distance', + query: '', + items: components, + options: { + maxDistance: 20, + isFuzzyMatch: true + } + }, + { + description: 'trimmed query', + query: ' button ', + items: components, + options: undefined + }, + { + description: 'empty items', + query: 'button', + items: [], + options: undefined + }, + { + description: 'single item', + query: 'button', + items: ['BUTTON'], + options: undefined + }, + { + description: 'multiple words', + query: 'ipsum dolor', + items: ['Lorem Ipsum Dolor Sit'], + options: { + maxDistance: 10 + } + } + ])('should fuzzy match, $description', ({ query, items, options }) => { + expect(fuzzySearch(query, items, options)).toMatchSnapshot(); + }); +}); diff --git a/src/server.helpers.ts b/src/server.helpers.ts index 761eaf50..d6a5bda8 100644 --- a/src/server.helpers.ts +++ b/src/server.helpers.ts @@ -1,4 +1,5 @@ import { createHash } from 'crypto'; +import { distance, closest } from 'fastest-levenshtein'; /** * Simple hash from content. @@ -19,4 +20,131 @@ const generateHash = (content: unknown) => */ const isPromise = (obj: unknown) => /^\[object (Promise|Async|AsyncFunction)]/.test(Object.prototype.toString.call(obj)); -export { generateHash, isPromise }; +/** + * Fuzzy search result using fastest-levenshtein + */ +interface FuzzySearchResult { + item: string; + distance: number; + matchType: 'exact' | 'prefix' | 'contains' | 'fuzzy'; +} + +/** + * Options for fuzzy search + */ +interface FuzzySearchOptions { + maxDistance?: number; + maxResults?: number; + isExactMatch?: boolean; + isPrefixMatch?: boolean; + isContainsMatch?: boolean; + isFuzzyMatch?: boolean; +} + +/** + * Find the closest match using fastest-levenshtein's closest function. + * + * User input is trimmed to handle accidental spaces. + * + * @param query - Search query string + * @param items - Array of strings to search + * @returns {string | null} Closest matching string or null + * + * @example + * ```typescript + * const result = findClosest('button', ['Button', 'ButtonGroup', 'Badge']); + * // Returns: 'Button' (the closest match) + * ``` + */ +const findClosest = ( + query: string, + items: string[] +): string | null => { + const queryLower = query.toLowerCase().trim(); + + return closest(queryLower, items) || null; +}; + +/** + * Fuzzy search using fastest-levenshtein + * + * User input is trimmed to handle accidental spaces. + * + * @param query - Search query string + * @param items - Array of strings to search + * @param options - Search configuration options + * @returns {FuzzySearchResult[]} Array of matching strings with distance and match type + * + * @example + * ```typescript + * const results = fuzzySearch('button', ['Button', 'ButtonGroup', 'Badge'], { + * maxDistance: 3, + * maxResults: 5 + * }); + * // Returns: [{ item: 'Button', distance: 0, matchType: 'exact' }, ...] + * ``` + */ +const fuzzySearch = ( + query: string, + items: string[], + options: FuzzySearchOptions = {} +): FuzzySearchResult[] => { + const { + maxDistance = 3, + maxResults = 10, + isExactMatch = true, + isPrefixMatch = true, + isContainsMatch = true, + isFuzzyMatch = false + } = options; + + const queryLower = query.trim().toLowerCase(); + const results: FuzzySearchResult[] = []; + + items.forEach(item => { + const itemLower = item.toLowerCase(); + const editDistance = distance(queryLower, itemLower); + + let matchType: FuzzySearchResult['matchType']; + + if (editDistance === 0) { + matchType = 'exact'; + } else if (itemLower.startsWith(queryLower)) { + matchType = 'prefix'; + } else if (itemLower.includes(queryLower)) { + matchType = 'contains'; + } else { + matchType = 'fuzzy'; + } + + const isIncluded = (matchType === 'exact' && isExactMatch) || (matchType === 'prefix' && isPrefixMatch) || (matchType === 'contains' && isContainsMatch) || (matchType === 'fuzzy' && isFuzzyMatch); + + if (editDistance <= maxDistance && isIncluded) { + results.push({ + item, + distance: editDistance, + matchType + }); + } + }); + + // Sort by distance (lowest first), then alphabetically + results.sort((a, b) => { + if (a.distance !== b.distance) { + return a.distance - b.distance; + } + + return a.item.localeCompare(b.item); + }); + + return results.slice(0, maxResults); +}; + +export { + generateHash, + isPromise, + fuzzySearch, + findClosest, + type FuzzySearchResult, + type FuzzySearchOptions +}; From 79cc5c972a270bad32327aee83cab2d3ca104561 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Wed, 5 Nov 2025 23:25:47 -0500 Subject: [PATCH 2/4] refactor: skip unnecessary distance checks --- src/server.helpers.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/server.helpers.ts b/src/server.helpers.ts index d6a5bda8..c308a423 100644 --- a/src/server.helpers.ts +++ b/src/server.helpers.ts @@ -103,18 +103,24 @@ const fuzzySearch = ( items.forEach(item => { const itemLower = item.toLowerCase(); - const editDistance = distance(queryLower, itemLower); + let editDistance = 0; + let matchType: FuzzySearchResult['matchType'] | undefined; - let matchType: FuzzySearchResult['matchType']; - - if (editDistance === 0) { + if (itemLower === queryLower) { matchType = 'exact'; } else if (itemLower.startsWith(queryLower)) { matchType = 'prefix'; + editDistance = distance(queryLower, itemLower); } else if (itemLower.includes(queryLower)) { matchType = 'contains'; - } else { + editDistance = distance(queryLower, itemLower); + } else if (isFuzzyMatch) { matchType = 'fuzzy'; + editDistance = distance(queryLower, itemLower); + } + + if (matchType === undefined) { + return; } const isIncluded = (matchType === 'exact' && isExactMatch) || (matchType === 'prefix' && isPrefixMatch) || (matchType === 'contains' && isContainsMatch) || (matchType === 'fuzzy' && isFuzzyMatch); From dc5f3cafac4f0408a508246aeea4a4c0289a67b5 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Thu, 6 Nov 2025 00:04:22 -0500 Subject: [PATCH 3/4] refactor: streamline, missing tests --- .../__snapshots__/server.helpers.test.ts.snap | 274 ------------ .../__snapshots__/server.search.test.ts.snap | 417 ++++++++++++++++++ src/__tests__/server.helpers.test.ts | 180 +------- src/__tests__/server.search.test.ts | 288 ++++++++++++ src/server.helpers.ts | 133 +----- src/server.search.ts | 201 +++++++++ 6 files changed, 908 insertions(+), 585 deletions(-) create mode 100644 src/__tests__/__snapshots__/server.search.test.ts.snap create mode 100644 src/__tests__/server.search.test.ts create mode 100644 src/server.search.ts diff --git a/src/__tests__/__snapshots__/server.helpers.test.ts.snap b/src/__tests__/__snapshots__/server.helpers.test.ts.snap index 83b19b5d..eb6018df 100644 --- a/src/__tests__/__snapshots__/server.helpers.test.ts.snap +++ b/src/__tests__/__snapshots__/server.helpers.test.ts.snap @@ -1,279 +1,5 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`findClosest should attempt to find a closest match, empty haystack 1`] = ` -{ - "match": null, - "query": "Button", -} -`; - -exports[`findClosest should attempt to find a closest match, empty needle 1`] = ` -{ - "match": "Badge", - "query": "", -} -`; - -exports[`findClosest should attempt to find a closest match, exact match 1`] = ` -{ - "match": "Alert", - "query": "Alert", -} -`; - -exports[`findClosest should attempt to find a closest match, match spacing 1`] = ` -{ - "match": "dolor sit", - "query": "dolor sit", -} -`; - -exports[`findClosest should attempt to find a closest match, multiple matches 1`] = ` -{ - "match": "Badge", - "query": "badge", -} -`; - -exports[`findClosest should attempt to find a closest match, multiple matches with case insensitive search 1`] = ` -{ - "match": "Badge", - "query": "BADGE", -} -`; - -exports[`findClosest should attempt to find a closest match, non-existent needle 1`] = ` -{ - "match": "Alert", - "query": "lorem", -} -`; - -exports[`findClosest should attempt to find a closest match, non-existent needle with case insensitive search 1`] = ` -{ - "match": "Alert", - "query": "LOREM", -} -`; - -exports[`findClosest should attempt to find a closest match, partial query 1`] = ` -{ - "match": "Button", - "query": "but", -} -`; - -exports[`findClosest should attempt to find a closest match, typo 1`] = ` -{ - "match": "Button", - "query": "buton", -} -`; - -exports[`fuzzySearch should fuzzy match, contains match multiple 1`] = ` -[ - { - "distance": 6, - "item": "AlertGroup", - "matchType": "contains", - }, - { - "distance": 6, - "item": "BadgeGroup", - "matchType": "contains", - }, - { - "distance": 7, - "item": "ButtonGroup", - "matchType": "contains", - }, -] -`; - -exports[`fuzzySearch should fuzzy match, empty items 1`] = `[]`; - -exports[`fuzzySearch should fuzzy match, empty query 1`] = `[]`; - -exports[`fuzzySearch should fuzzy match, empty query extended distance 1`] = ` -[ - { - "distance": 4, - "item": "Card", - "matchType": "prefix", - }, - { - "distance": 5, - "item": "Alert", - "matchType": "prefix", - }, - { - "distance": 5, - "item": "Badge", - "matchType": "prefix", - }, - { - "distance": 6, - "item": "Button", - "matchType": "prefix", - }, - { - "distance": 10, - "item": "AlertGroup", - "matchType": "prefix", - }, - { - "distance": 10, - "item": "BadgeGroup", - "matchType": "prefix", - }, - { - "distance": 10, - "item": "CardHeader", - "matchType": "prefix", - }, - { - "distance": 11, - "item": "ButtonGroup", - "matchType": "prefix", - }, -] -`; - -exports[`fuzzySearch should fuzzy match, exact match 1`] = ` -[ - { - "distance": 0, - "item": "Button", - "matchType": "exact", - }, -] -`; - -exports[`fuzzySearch should fuzzy match, exact match case-insensitive 1`] = ` -[ - { - "distance": 0, - "item": "Button", - "matchType": "exact", - }, -] -`; - -exports[`fuzzySearch should fuzzy match, fuzzy match within distance 1`] = ` -[ - { - "distance": 5, - "item": "Badge", - "matchType": "fuzzy", - }, - { - "distance": 6, - "item": "Alert", - "matchType": "fuzzy", - }, - { - "distance": 6, - "item": "Card", - "matchType": "fuzzy", - }, - { - "distance": 8, - "item": "AlertGroup", - "matchType": "fuzzy", - }, - { - "distance": 8, - "item": "BadgeGroup", - "matchType": "fuzzy", - }, - { - "distance": 10, - "item": "CardHeader", - "matchType": "fuzzy", - }, -] -`; - -exports[`fuzzySearch should fuzzy match, match within max results 1`] = ` -[ - { - "distance": 3, - "item": "Card", - "matchType": "contains", - }, - { - "distance": 4, - "item": "Alert", - "matchType": "prefix", - }, -] -`; - -exports[`fuzzySearch should fuzzy match, match within restricted distance 1`] = ` -[ - { - "distance": 0, - "item": "Button", - "matchType": "exact", - }, -] -`; - -exports[`fuzzySearch should fuzzy match, multiple words 1`] = ` -[ - { - "distance": 10, - "item": "Lorem Ipsum Dolor Sit", - "matchType": "contains", - }, -] -`; - -exports[`fuzzySearch should fuzzy match, prefix match 1`] = ` -[ - { - "distance": 3, - "item": "Button", - "matchType": "prefix", - }, -] -`; - -exports[`fuzzySearch should fuzzy match, prefix match multiple 1`] = ` -[ - { - "distance": 2, - "item": "Button", - "matchType": "prefix", - }, - { - "distance": 7, - "item": "ButtonGroup", - "matchType": "prefix", - }, -] -`; - -exports[`fuzzySearch should fuzzy match, single item 1`] = ` -[ - { - "distance": 0, - "item": "BUTTON", - "matchType": "exact", - }, -] -`; - -exports[`fuzzySearch should fuzzy match, trimmed query 1`] = ` -[ - { - "distance": 0, - "item": "Button", - "matchType": "exact", - }, -] -`; - exports[`generateHash should minimally generate a consistent hash: hash, object and primitive values 1`] = ` { "valueArray": "0e8e2b3096e6aee20e3cd10b7788ce3d72502061", diff --git a/src/__tests__/__snapshots__/server.search.test.ts.snap b/src/__tests__/__snapshots__/server.search.test.ts.snap new file mode 100644 index 00000000..72f4705b --- /dev/null +++ b/src/__tests__/__snapshots__/server.search.test.ts.snap @@ -0,0 +1,417 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`findClosest should attempt to find a closest match, empty haystack 1`] = ` +{ + "match": null, + "query": "Button", +} +`; + +exports[`findClosest should attempt to find a closest match, empty needle 1`] = ` +{ + "match": null, + "query": "", +} +`; + +exports[`findClosest should attempt to find a closest match, exact match 1`] = ` +{ + "match": "Alert", + "query": "Alert", +} +`; + +exports[`findClosest should attempt to find a closest match, match spacing 1`] = ` +{ + "match": "dolor sit", + "query": "dolor sit", +} +`; + +exports[`findClosest should attempt to find a closest match, multiple matches 1`] = ` +{ + "match": "Badge", + "query": "badge", +} +`; + +exports[`findClosest should attempt to find a closest match, multiple matches with case insensitive search 1`] = ` +{ + "match": "Badge", + "query": "BADGE", +} +`; + +exports[`findClosest should attempt to find a closest match, non-existent needle 1`] = ` +{ + "match": "Alert", + "query": "lorem", +} +`; + +exports[`findClosest should attempt to find a closest match, non-existent needle with case insensitive search 1`] = ` +{ + "match": "Alert", + "query": "LOREM", +} +`; + +exports[`findClosest should attempt to find a closest match, null items 1`] = ` +{ + "match": null, + "query": "Button", +} +`; + +exports[`findClosest should attempt to find a closest match, partial query 1`] = ` +{ + "match": "Button", + "query": "but", +} +`; + +exports[`findClosest should attempt to find a closest match, typo 1`] = ` +{ + "match": "Button", + "query": "buton", +} +`; + +exports[`findClosest should attempt to find a closest match, undefined items 1`] = ` +{ + "match": null, + "query": "Button", +} +`; + +exports[`fuzzySearch should fuzzy match, contains match multiple 1`] = ` +[ + { + "distance": 1, + "item": "AlertGroup", + "matchType": "suffix", + }, + { + "distance": 1, + "item": "BadgeGroup", + "matchType": "suffix", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "suffix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, duplicate items 1`] = ` +[ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, empty items 1`] = `[]`; + +exports[`fuzzySearch should fuzzy match, empty query 1`] = `[]`; + +exports[`fuzzySearch should fuzzy match, empty query against maxDistance 1`] = ` +[ + { + "distance": 1, + "item": "A", + "matchType": "fuzzy", + }, + { + "distance": 2, + "item": "AB", + "matchType": "fuzzy", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, empty query extended distance 1`] = ` +[ + { + "distance": 4, + "item": "Card", + "matchType": "fuzzy", + }, + { + "distance": 5, + "item": "Alert", + "matchType": "fuzzy", + }, + { + "distance": 5, + "item": "Badge", + "matchType": "fuzzy", + }, + { + "distance": 6, + "item": "Button", + "matchType": "fuzzy", + }, + { + "distance": 10, + "item": "AlertGroup", + "matchType": "fuzzy", + }, + { + "distance": 10, + "item": "BadgeGroup", + "matchType": "fuzzy", + }, + { + "distance": 10, + "item": "CardHeader", + "matchType": "fuzzy", + }, + { + "distance": 11, + "item": "ButtonGroup", + "matchType": "fuzzy", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, exact match 1`] = ` +[ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, exact match case-insensitive 1`] = ` +[ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, fuzzy match within distance 1`] = ` +[ + { + "distance": 5, + "item": "Badge", + "matchType": "fuzzy", + }, + { + "distance": 6, + "item": "Alert", + "matchType": "fuzzy", + }, + { + "distance": 6, + "item": "Card", + "matchType": "fuzzy", + }, + { + "distance": 8, + "item": "AlertGroup", + "matchType": "fuzzy", + }, + { + "distance": 8, + "item": "BadgeGroup", + "matchType": "fuzzy", + }, + { + "distance": 10, + "item": "CardHeader", + "matchType": "fuzzy", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, length-delta precheck for maxDistance 1`] = `[]`; + +exports[`fuzzySearch should fuzzy match, match within max results 1`] = ` +[ + { + "distance": 1, + "item": "Alert", + "matchType": "prefix", + }, + { + "distance": 1, + "item": "AlertGroup", + "matchType": "prefix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, match within restricted distance 1`] = ` +[ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, matches are alphabetized 1`] = ` +[ + { + "distance": 1, + "item": "Button", + "matchType": "prefix", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, matches are normalized 1`] = ` +[ + { + "distance": 0, + "item": "resume", + "matchType": "exact", + }, + { + "distance": 0, + "item": "RESUME", + "matchType": "exact", + }, + { + "distance": 0, + "item": "Résumé", + "matchType": "exact", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, mixed types by maxDistance 1`] = ` +[ + { + "distance": 1, + "item": "Button", + "matchType": "prefix", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, multiple words 1`] = ` +[ + { + "distance": 1, + "item": "BadgeGroup", + "matchType": "fuzzy", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, multiple words maxDistance 1`] = ` +[ + { + "distance": 2, + "item": "Lorem Ipsum Dolor Sit", + "matchType": "contains", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, negative maxDistance 1`] = `[]`; + +exports[`fuzzySearch should fuzzy match, null items 1`] = `[]`; + +exports[`fuzzySearch should fuzzy match, prefix match 1`] = ` +[ + { + "distance": 1, + "item": "Button", + "matchType": "prefix", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, prefix match multiple 1`] = ` +[ + { + "distance": 1, + "item": "Button", + "matchType": "prefix", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, single item 1`] = ` +[ + { + "distance": 0, + "item": "BUTTON", + "matchType": "exact", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, suffix match 1`] = ` +[ + { + "distance": 1, + "item": "CardHeader", + "matchType": "suffix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, trimmed query 1`] = ` +[ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, +] +`; + +exports[`fuzzySearch should fuzzy match, undefined items 1`] = `[]`; diff --git a/src/__tests__/server.helpers.test.ts b/src/__tests__/server.helpers.test.ts index c0480d2e..8451ee07 100644 --- a/src/__tests__/server.helpers.test.ts +++ b/src/__tests__/server.helpers.test.ts @@ -1,4 +1,4 @@ -import { generateHash, isPromise, fuzzySearch, findClosest } from '../server.helpers'; +import { generateHash, isPromise } from '../server.helpers'; describe('generateHash', () => { it('should minimally generate a consistent hash', () => { @@ -48,181 +48,3 @@ describe('isPromise', () => { expect(isPromise(func)).toBe(value); }); }); - -describe('findClosest', () => { - const components = ['Button', 'ButtonGroup', 'Badge', 'BadgeGroup', 'Alert', 'AlertGroup']; - - it.each([ - { - description: 'empty haystack', - query: 'Button', - items: [] - }, - { - description: 'empty needle', - query: '', - items: components - }, - { - description: 'non-existent needle', - query: 'lorem', - items: components - }, - { - description: 'non-existent needle with case insensitive search', - query: 'LOREM', - items: components - }, - { - description: 'exact match', - query: 'Alert', - items: components - }, - { - description: 'partial query', - query: 'but', - items: components - }, - { - description: 'typo', - query: 'buton', - items: components - }, - { - description: 'multiple matches', - query: 'badge', - items: components - }, - { - description: 'multiple matches with case insensitive search', - query: 'BADGE', - items: components - }, - { - description: 'match spacing', - query: 'dolor sit', - items: ['sit', 'dolor', 'dolor sit'] - } - ])('should attempt to find a closest match, $description', ({ query, items }) => { - expect({ - query, - match: findClosest(query, items) - }).toMatchSnapshot(); - }); -}); - -describe('fuzzySearch', () => { - const components = ['Button', 'ButtonGroup', 'Badge', 'BadgeGroup', 'Alert', 'AlertGroup', 'Card', 'CardHeader']; - - it.each([ - { - description: 'exact match', - query: 'Button', - items: components, - options: undefined - }, - { - description: 'exact match case-insensitive', - query: 'button', - items: components, - options: undefined - }, - { - description: 'prefix match', - query: 'but', - items: components, - options: undefined - }, - { - description: 'prefix match multiple', - query: 'butt', - items: components, - options: { - maxDistance: 10 - } - }, - { - description: 'contains match multiple', - query: 'roup', - items: components, - options: { - maxDistance: 10 - } - }, - { - description: 'fuzzy match within distance', - query: 'button', - items: components, - options: { - maxDistance: 10, - isExactMatch: false, - isPrefixMatch: false, - isContainsMatch: false, - isFuzzyMatch: true - } - }, - { - description: 'match within max results', - query: 'a', - items: components, - options: { - maxDistance: 10, - maxResults: 2, - isFuzzyMatch: true - } - }, - { - description: 'match within restricted distance', - query: 'button', - items: components, - options: { - maxDistance: 1 - } - }, - { - description: 'empty query', - query: '', - items: components, - options: { - isFuzzyMatch: true - } - }, - { - description: 'empty query extended distance', - query: '', - items: components, - options: { - maxDistance: 20, - isFuzzyMatch: true - } - }, - { - description: 'trimmed query', - query: ' button ', - items: components, - options: undefined - }, - { - description: 'empty items', - query: 'button', - items: [], - options: undefined - }, - { - description: 'single item', - query: 'button', - items: ['BUTTON'], - options: undefined - }, - { - description: 'multiple words', - query: 'ipsum dolor', - items: ['Lorem Ipsum Dolor Sit'], - options: { - maxDistance: 10 - } - } - ])('should fuzzy match, $description', ({ query, items, options }) => { - expect(fuzzySearch(query, items, options)).toMatchSnapshot(); - }); -}); diff --git a/src/__tests__/server.search.test.ts b/src/__tests__/server.search.test.ts new file mode 100644 index 00000000..bc4fd684 --- /dev/null +++ b/src/__tests__/server.search.test.ts @@ -0,0 +1,288 @@ +import { normalizeString, fuzzySearch, findClosest } from '../server.search'; + +describe('normalizeString', () => { + it('should normalize a string', () => { + expect(normalizeString('résumé')).toBe(normalizeString('resume')); + }); +}); + +describe('findClosest', () => { + const components = ['Button', 'ButtonGroup', 'Badge', 'BadgeGroup', 'Alert', 'AlertGroup']; + + it.each([ + { + description: 'undefined items', + query: 'Button', + items: undefined + }, + { + description: 'null items', + query: 'Button', + items: null + }, + { + description: 'empty haystack', + query: 'Button', + items: [] + }, + { + description: 'empty needle', + query: '', + items: components + }, + { + description: 'non-existent needle', + query: 'lorem', + items: components + }, + { + description: 'non-existent needle with case insensitive search', + query: 'LOREM', + items: components + }, + { + description: 'exact match', + query: 'Alert', + items: components + }, + { + description: 'partial query', + query: 'but', + items: components + }, + { + description: 'typo', + query: 'buton', + items: components + }, + { + description: 'multiple matches', + query: 'badge', + items: components + }, + { + description: 'multiple matches with case insensitive search', + query: 'BADGE', + items: components + }, + { + description: 'match spacing', + query: 'dolor sit', + items: ['sit', 'dolor', 'dolor sit'] + } + ])('should attempt to find a closest match, $description', ({ query, items }) => { + expect({ + query, + match: findClosest(query, items as string[]) + }).toMatchSnapshot(); + }); +}); + +describe('fuzzySearch', () => { + const components = ['Button', 'ButtonGroup', 'Badge', 'BadgeGroup', 'Alert', 'AlertGroup', 'Card', 'CardHeader']; + + it.each([ + { + description: 'undefined items', + query: 'Button', + items: undefined, + options: undefined + }, + { + description: 'null items', + query: 'Button', + items: null, + options: undefined + }, + { + description: 'exact match', + query: 'Button', + items: components, + options: undefined + }, + { + description: 'exact match case-insensitive', + query: 'button', + items: components, + options: undefined + }, + { + description: 'prefix match', + query: 'but', + items: components, + options: undefined + }, + { + description: 'prefix match multiple', + query: 'butt', + items: components, + options: { + maxDistance: 10 + } + }, + { + description: 'contains match multiple', + query: 'roup', + items: components, + options: { + maxDistance: 10 + } + }, + { + description: 'fuzzy match within distance', + query: 'button', + items: components, + options: { + maxDistance: 10, + isExactMatch: false, + isPrefixMatch: false, + isSuffixMatch: false, + isContainsMatch: false, + isFuzzyMatch: true + } + }, + { + description: 'match within max results', + query: 'a', + items: components, + options: { + maxDistance: 10, + maxResults: 2, + isFuzzyMatch: true + } + }, + { + description: 'match within restricted distance', + query: 'button', + items: components, + options: { + maxDistance: 1 + } + }, + { + description: 'empty query', + query: '', + items: components, + options: { + isFuzzyMatch: true + } + }, + { + description: 'empty query extended distance', + query: '', + items: components, + options: { + maxDistance: 20, + isFuzzyMatch: true + } + }, + { + description: 'trimmed query', + query: ' button ', + items: components, + options: undefined + }, + { + description: 'empty items', + query: 'button', + items: [], + options: undefined + }, + { + description: 'single item', + query: 'button', + items: ['BUTTON'], + options: undefined + }, + { + description: 'multiple words maxDistance', + query: 'ipsum dolor', + items: ['Lorem Ipsum Dolor Sit'], + options: { + maxDistance: 10 + } + }, + { + description: 'multiple words', + query: 'badge group', + items: ['BadgeGroup'], + options: { + isFuzzyMatch: true, + maxDistance: 2 + } + }, + { + description: 'negative maxDistance', + query: 'button', + items: ['Button'], + options: { + maxDistance: -1 + } + }, + { + description: 'empty query against maxDistance', + query: '', + items: ['A', 'AB', 'ABCDE', 'ABCDEFG'], + options: { + maxDistance: 3, + isFuzzyMatch: true + } + }, + { + description: 'length-delta precheck for maxDistance', + query: 'AB', + items: ['ABCDEFGH'], + options: { + maxDistance: 2, + isExactMatch: false, + isPrefixMatch: false, + isSuffixMatch: false, + isFuzzyMatch: true + } + }, + { + description: 'duplicate items', + query: 'button', + items: ['Button', 'Button', 'Button'], + options: { + maxDistance: 10 + } + }, + { + description: 'suffix match', + query: 'header', + items: ['Card', 'CardHeader'], + options: { + isExactMatch: false, + isPrefixMatch: false, + isContainsMatch: false, + isFuzzyMatch: false + } + }, + { + description: 'mixed types by maxDistance', + query: 'butto', + items: ['Button', 'ButtonGroup', 'Burrito'], + options: { + maxDistance: 1, + isFuzzyMatch: true + } + }, + { + description: 'matches are alphabetized', + query: 'butt', + items: ['ButtonGroup', 'Button'], + options: { + maxDistance: 10 + } + }, + { + description: 'matches are normalized', + query: 'resume', + items: ['Résumé', 'resume', 'RESUME'], + options: undefined + } + ])('should fuzzy match, $description', ({ query, items, options }) => { + expect(fuzzySearch(query, items as string[], options)).toMatchSnapshot(); + }); +}); diff --git a/src/server.helpers.ts b/src/server.helpers.ts index c308a423..b7d18f3a 100644 --- a/src/server.helpers.ts +++ b/src/server.helpers.ts @@ -1,5 +1,4 @@ import { createHash } from 'crypto'; -import { distance, closest } from 'fastest-levenshtein'; /** * Simple hash from content. @@ -20,137 +19,7 @@ const generateHash = (content: unknown) => */ const isPromise = (obj: unknown) => /^\[object (Promise|Async|AsyncFunction)]/.test(Object.prototype.toString.call(obj)); -/** - * Fuzzy search result using fastest-levenshtein - */ -interface FuzzySearchResult { - item: string; - distance: number; - matchType: 'exact' | 'prefix' | 'contains' | 'fuzzy'; -} - -/** - * Options for fuzzy search - */ -interface FuzzySearchOptions { - maxDistance?: number; - maxResults?: number; - isExactMatch?: boolean; - isPrefixMatch?: boolean; - isContainsMatch?: boolean; - isFuzzyMatch?: boolean; -} - -/** - * Find the closest match using fastest-levenshtein's closest function. - * - * User input is trimmed to handle accidental spaces. - * - * @param query - Search query string - * @param items - Array of strings to search - * @returns {string | null} Closest matching string or null - * - * @example - * ```typescript - * const result = findClosest('button', ['Button', 'ButtonGroup', 'Badge']); - * // Returns: 'Button' (the closest match) - * ``` - */ -const findClosest = ( - query: string, - items: string[] -): string | null => { - const queryLower = query.toLowerCase().trim(); - - return closest(queryLower, items) || null; -}; - -/** - * Fuzzy search using fastest-levenshtein - * - * User input is trimmed to handle accidental spaces. - * - * @param query - Search query string - * @param items - Array of strings to search - * @param options - Search configuration options - * @returns {FuzzySearchResult[]} Array of matching strings with distance and match type - * - * @example - * ```typescript - * const results = fuzzySearch('button', ['Button', 'ButtonGroup', 'Badge'], { - * maxDistance: 3, - * maxResults: 5 - * }); - * // Returns: [{ item: 'Button', distance: 0, matchType: 'exact' }, ...] - * ``` - */ -const fuzzySearch = ( - query: string, - items: string[], - options: FuzzySearchOptions = {} -): FuzzySearchResult[] => { - const { - maxDistance = 3, - maxResults = 10, - isExactMatch = true, - isPrefixMatch = true, - isContainsMatch = true, - isFuzzyMatch = false - } = options; - - const queryLower = query.trim().toLowerCase(); - const results: FuzzySearchResult[] = []; - - items.forEach(item => { - const itemLower = item.toLowerCase(); - let editDistance = 0; - let matchType: FuzzySearchResult['matchType'] | undefined; - - if (itemLower === queryLower) { - matchType = 'exact'; - } else if (itemLower.startsWith(queryLower)) { - matchType = 'prefix'; - editDistance = distance(queryLower, itemLower); - } else if (itemLower.includes(queryLower)) { - matchType = 'contains'; - editDistance = distance(queryLower, itemLower); - } else if (isFuzzyMatch) { - matchType = 'fuzzy'; - editDistance = distance(queryLower, itemLower); - } - - if (matchType === undefined) { - return; - } - - const isIncluded = (matchType === 'exact' && isExactMatch) || (matchType === 'prefix' && isPrefixMatch) || (matchType === 'contains' && isContainsMatch) || (matchType === 'fuzzy' && isFuzzyMatch); - - if (editDistance <= maxDistance && isIncluded) { - results.push({ - item, - distance: editDistance, - matchType - }); - } - }); - - // Sort by distance (lowest first), then alphabetically - results.sort((a, b) => { - if (a.distance !== b.distance) { - return a.distance - b.distance; - } - - return a.item.localeCompare(b.item); - }); - - return results.slice(0, maxResults); -}; - export { generateHash, - isPromise, - fuzzySearch, - findClosest, - type FuzzySearchResult, - type FuzzySearchOptions + isPromise }; diff --git a/src/server.search.ts b/src/server.search.ts new file mode 100644 index 00000000..9b133be8 --- /dev/null +++ b/src/server.search.ts @@ -0,0 +1,201 @@ +import { distance, closest } from 'fastest-levenshtein'; + +/** + * Options for closest search + */ +interface ClosestSearchOptions { + normalizeFn?: (str: string) => string; +} + +/** + * Fuzzy search result using fastest-levenshtein + */ +interface FuzzySearchResult { + item: string; + distance: number; + matchType: 'exact' | 'prefix' | 'suffix' | 'contains' | 'fuzzy'; +} + +/** + * Options for fuzzy search + * + * - `maxDistance` - Maximum edit distance for a match. Distance is defined as + * - exact = 0 + * - prefix = 1 + * - suffix = 1 + * - contains = 2 + * - fuzzy = Levenshtein edit distance + * - `maxResults` - Maximum number of results to return + * - `normalizeFn` - Function to normalize strings (default: `normalizeString`) + * - `isExactMatch` | `isPrefixMatch` | `isSuffixMatch` | `isContainsMatch` | `isFuzzyMatch` - Enable specific match modes + */ +interface FuzzySearchOptions { + maxDistance?: number; + maxResults?: number; + normalizeFn?: (str: string) => string; + isExactMatch?: boolean; + isPrefixMatch?: boolean; + isSuffixMatch?: boolean; + isContainsMatch?: boolean; + isFuzzyMatch?: boolean; +} + +/** + * Internal lightweight normalization: trim, lowercase, remove diacritics (a sign/accent character), squash separators + * + * - Functions `findClosest` and `fuzzySearch` use this internally. + * - Can be overridden in the `findClosest` and `fuzzySearch` related options for custom normalization. + * + * @param str + */ +const normalizeString = (str: string) => String(str || '') + .trim() + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[\s_-]+/g, ' ') + .replace(/\s+/g, ' '); + +/** + * Find the closest match using fastest-levenshtein's closest function. + * + * - Returns the first original item whose normalized value equals the best normalized candidate. + * + * @param query - Search query string + * @param items - Array of strings to search + * @param {ClosestSearchOptions} options - Search configuration options + * @returns {string | null} Closest matching string or null + * + * @example + * ```typescript + * const result = findClosest('button', ['Button', 'ButtonGroup', 'Badge']); + * // Returns: 'Button' (the closest match) + * ``` + */ +const findClosest = ( + query: string, + items: string[] = [], + { + normalizeFn = normalizeString + }: ClosestSearchOptions = {} +) => { + const normalizedQuery = normalizeFn(query); + + if (!normalizedQuery || !Array.isArray(items) || items.length === 0) { + return null; + } + + const normalizedItems = items.map(item => (item ? normalizeFn(item) : item)); + const closestMatch = closest(normalizedQuery, normalizedItems); + + return items[normalizedItems.indexOf(closestMatch)] || null; +}; + +/** + * Fuzzy search using fastest-levenshtein + * + * - Exact/prefix/suffix/contains are evaluated first with constant distances (0/1/1/2). + * - Fuzzy distance is computed only when earlier classifications fail and only when the + * string length delta is within `maxDistance` (cheap lower-bound check). + * - Global filter `distance <= maxDistance` applies to all match types. + * - Empty-query fallback: if `query` normalizes to `''` and `isFuzzyMatch` is true, + * items with length `<= maxDistance` can match (since `distance('', s) = s.length`). + * + * @param query - Search query string + * @param items - Array of strings to search + * @param {FuzzySearchOptions} options - Search configuration options + * @returns {FuzzySearchResult[]} Array of matching strings with distance and match type + * + * @example + * ```typescript + * const results = fuzzySearch('button', ['Button', 'ButtonGroup', 'Badge'], { + * maxDistance: 3, + * maxResults: 5 + * }); + * // Returns: [{ item: 'Button', distance: 0, matchType: 'exact' }, ...] + * ``` + */ +const fuzzySearch = ( + query: string, + items: string[] = [], + { + maxDistance = 3, + maxResults = 10, + normalizeFn = normalizeString, + isExactMatch = true, + isPrefixMatch = true, + isSuffixMatch = true, + isContainsMatch = true, + isFuzzyMatch = false + }: FuzzySearchOptions = {} +): FuzzySearchResult[] => { + const normalizedQuery = normalizeFn(query); + const seenItem = new Set(); + const results: FuzzySearchResult[] = []; + + items?.forEach(item => { + if (seenItem.has(item)) { + return; + } + + seenItem.add(item); + + const normalizedItem = normalizeFn(item); + let editDistance = 0; + let matchType: FuzzySearchResult['matchType'] | undefined; + + if (normalizedItem === normalizedQuery) { + matchType = 'exact'; + } else if (normalizedQuery !== '' && normalizedItem.startsWith(normalizedQuery)) { + matchType = 'prefix'; + editDistance = 1; + } else if (normalizedQuery !== '' && normalizedItem.endsWith(normalizedQuery)) { + matchType = 'suffix'; + editDistance = 1; + } else if (normalizedQuery !== '' && normalizedItem.includes(normalizedQuery)) { + matchType = 'contains'; + editDistance = 2; + } else if (isFuzzyMatch && Math.abs(normalizedItem.length - normalizedQuery.length) <= maxDistance) { + matchType = 'fuzzy'; + editDistance = distance(normalizedQuery, normalizedItem); + } + + if (matchType === undefined) { + return; + } + + const isIncluded = (matchType === 'exact' && isExactMatch) || + (matchType === 'prefix' && isPrefixMatch) || + (matchType === 'suffix' && isSuffixMatch) || + (matchType === 'contains' && isContainsMatch) || + (matchType === 'fuzzy' && isFuzzyMatch); + + if (editDistance <= maxDistance && isIncluded) { + results.push({ + item, + distance: editDistance, + matchType + }); + } + }); + + // Sort by distance (lowest first), then alphabetically + results.sort((a, b) => { + if (a.distance !== b.distance) { + return a.distance - b.distance; + } + + return a.item.localeCompare(b.item); + }); + + return results.slice(0, maxResults); +}; + +export { + normalizeString, + fuzzySearch, + findClosest, + type ClosestSearchOptions, + type FuzzySearchResult, + type FuzzySearchOptions +}; From 58138bcdee99956200d82be9f78d1557a9f1f891 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Thu, 6 Nov 2025 11:07:12 -0500 Subject: [PATCH 4/4] refactor: clean up, memo normalize, annotations, typing --- .../__snapshots__/server.search.test.ts.snap | 17 +++++++ src/__tests__/server.search.test.ts | 37 ++++++++++++++ src/server.search.ts | 49 ++++++++++++++----- 3 files changed, 90 insertions(+), 13 deletions(-) diff --git a/src/__tests__/__snapshots__/server.search.test.ts.snap b/src/__tests__/__snapshots__/server.search.test.ts.snap index 72f4705b..65a7e5ff 100644 --- a/src/__tests__/__snapshots__/server.search.test.ts.snap +++ b/src/__tests__/__snapshots__/server.search.test.ts.snap @@ -1,5 +1,12 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`findClosest should attempt to find a closest match, all empty string items 1`] = ` +{ + "match": null, + "query": "test", +} +`; + exports[`findClosest should attempt to find a closest match, empty haystack 1`] = ` { "match": null, @@ -104,6 +111,16 @@ exports[`fuzzySearch should fuzzy match, contains match multiple 1`] = ` ] `; +exports[`fuzzySearch should fuzzy match, deduplicate by normalized value 1`] = ` +[ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, +] +`; + exports[`fuzzySearch should fuzzy match, duplicate items 1`] = ` [ { diff --git a/src/__tests__/server.search.test.ts b/src/__tests__/server.search.test.ts index bc4fd684..dbe9b4f7 100644 --- a/src/__tests__/server.search.test.ts +++ b/src/__tests__/server.search.test.ts @@ -4,6 +4,10 @@ describe('normalizeString', () => { it('should normalize a string', () => { expect(normalizeString('résumé')).toBe(normalizeString('resume')); }); + + it('should have memo property', () => { + expect(normalizeString.memo).toBeDefined(); + }); }); describe('findClosest', () => { @@ -69,6 +73,11 @@ describe('findClosest', () => { description: 'match spacing', query: 'dolor sit', items: ['sit', 'dolor', 'dolor sit'] + }, + { + description: 'all empty string items', + query: 'test', + items: ['', '', ''] } ])('should attempt to find a closest match, $description', ({ query, items }) => { expect({ @@ -76,6 +85,16 @@ describe('findClosest', () => { match: findClosest(query, items as string[]) }).toMatchSnapshot(); }); + + it('should handle normalizeFn errors in findClosest', () => { + const throwingNormalizeFn = () => { + throw new Error('Normalization failed'); + }; + + expect(() => { + findClosest('button', ['Button', 'Badge'], { normalizeFn: throwingNormalizeFn }); + }).toThrow('Normalization failed'); + }); }); describe('fuzzySearch', () => { @@ -281,8 +300,26 @@ describe('fuzzySearch', () => { query: 'resume', items: ['Résumé', 'resume', 'RESUME'], options: undefined + }, + { + description: 'deduplicate by normalized value', + query: 'button', + items: ['Button', 'button', 'BUTTON'], + options: { + deduplicateByNormalized: true + } } ])('should fuzzy match, $description', ({ query, items, options }) => { expect(fuzzySearch(query, items as string[], options)).toMatchSnapshot(); }); + + it('should handle normalizeFn errors in fuzzySearch', () => { + const throwingNormalizeFn = () => { + throw new Error('Normalization failed'); + }; + + expect(() => { + fuzzySearch('button', ['Button', 'Badge'], { normalizeFn: throwingNormalizeFn }); + }).toThrow('Normalization failed'); + }); }); diff --git a/src/server.search.ts b/src/server.search.ts index 9b133be8..ad079012 100644 --- a/src/server.search.ts +++ b/src/server.search.ts @@ -1,4 +1,13 @@ import { distance, closest } from 'fastest-levenshtein'; +import { memo } from './server.caching'; + +/** + * normalizeString function interface + */ +interface NormalizeString { + (str: string): string; + memo: (str: string) => string; +} /** * Options for closest search @@ -28,6 +37,7 @@ interface FuzzySearchResult { * - `maxResults` - Maximum number of results to return * - `normalizeFn` - Function to normalize strings (default: `normalizeString`) * - `isExactMatch` | `isPrefixMatch` | `isSuffixMatch` | `isContainsMatch` | `isFuzzyMatch` - Enable specific match modes + * - `deduplicateByNormalized` - If true, deduplicate results by normalized value instead of original string (default: false) */ interface FuzzySearchOptions { maxDistance?: number; @@ -38,6 +48,7 @@ interface FuzzySearchOptions { isSuffixMatch?: boolean; isContainsMatch?: boolean; isFuzzyMatch?: boolean; + deduplicateByNormalized?: boolean; } /** @@ -45,10 +56,11 @@ interface FuzzySearchOptions { * * - Functions `findClosest` and `fuzzySearch` use this internally. * - Can be overridden in the `findClosest` and `fuzzySearch` related options for custom normalization. + * - Function has a `memo` property to allow use as a memoized function. * * @param str */ -const normalizeString = (str: string) => String(str || '') +const normalizeString: NormalizeString = (str: string) => String(str || '') .trim() .toLowerCase() .normalize('NFKD') @@ -56,10 +68,18 @@ const normalizeString = (str: string) => String(str || '') .replace(/[\s_-]+/g, ' ') .replace(/\s+/g, ' '); +/** + * Memoized version of normalizeString + */ +normalizeString.memo = memo(normalizeString, { cacheLimit: 25 }); + /** * Find the closest match using fastest-levenshtein's closest function. * - * - Returns the first original item whose normalized value equals the best normalized candidate. + * - Returns the **first** original item whose normalized value equals the best normalized candidate. + * - If multiple items normalize to the same value, only the first occurrence in the array is returned. + * - For multiple matches, use `fuzzySearch` instead. + * - Null/undefined items are normalized to empty strings to prevent runtime errors. * * @param query - Search query string * @param items - Array of strings to search @@ -76,7 +96,7 @@ const findClosest = ( query: string, items: string[] = [], { - normalizeFn = normalizeString + normalizeFn = normalizeString.memo }: ClosestSearchOptions = {} ) => { const normalizedQuery = normalizeFn(query); @@ -85,7 +105,7 @@ const findClosest = ( return null; } - const normalizedItems = items.map(item => (item ? normalizeFn(item) : item)); + const normalizedItems = items.map(item => (item ? normalizeFn(item) : '')); const closestMatch = closest(normalizedQuery, normalizedItems); return items[normalizedItems.indexOf(closestMatch)] || null; @@ -97,9 +117,9 @@ const findClosest = ( * - Exact/prefix/suffix/contains are evaluated first with constant distances (0/1/1/2). * - Fuzzy distance is computed only when earlier classifications fail and only when the * string length delta is within `maxDistance` (cheap lower-bound check). - * - Global filter `distance <= maxDistance` applies to all match types. - * - Empty-query fallback: if `query` normalizes to `''` and `isFuzzyMatch` is true, - * items with length `<= maxDistance` can match (since `distance('', s) = s.length`). + * - Global filter: result included only if its type is enabled AND distance <= maxDistance. + * - Negative `maxDistance` values intentionally filter out all results, including exact matches. + * - Empty-query fallback is allowed when `isFuzzyMatch` is true (items with length <= maxDistance can match). * * @param query - Search query string * @param items - Array of strings to search @@ -121,12 +141,13 @@ const fuzzySearch = ( { maxDistance = 3, maxResults = 10, - normalizeFn = normalizeString, + normalizeFn = normalizeString.memo, isExactMatch = true, isPrefixMatch = true, isSuffixMatch = true, isContainsMatch = true, - isFuzzyMatch = false + isFuzzyMatch = false, + deduplicateByNormalized = false }: FuzzySearchOptions = {} ): FuzzySearchResult[] => { const normalizedQuery = normalizeFn(query); @@ -134,13 +155,14 @@ const fuzzySearch = ( const results: FuzzySearchResult[] = []; items?.forEach(item => { - if (seenItem.has(item)) { + const normalizedItem = normalizeFn(item); + const deduplicationKey = deduplicateByNormalized ? normalizedItem : item; + + if (seenItem.has(deduplicationKey)) { return; } - seenItem.add(item); - - const normalizedItem = normalizeFn(item); + seenItem.add(deduplicationKey); let editDistance = 0; let matchType: FuzzySearchResult['matchType'] | undefined; @@ -195,6 +217,7 @@ export { normalizeString, fuzzySearch, findClosest, + type NormalizeString, type ClosestSearchOptions, type FuzzySearchResult, type FuzzySearchOptions