diff --git a/.changeset/lazy-games-create.md b/.changeset/lazy-games-create.md new file mode 100644 index 000000000..05b63c9d3 --- /dev/null +++ b/.changeset/lazy-games-create.md @@ -0,0 +1,5 @@ +--- +"@scaleway/fuzzy-search": major +--- + +feat: create fuzzy search package diff --git a/README.md b/README.md index 9f77cc41d..a6681d7c5 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,6 @@ scaleway-lib is a set of NPM packages used at Scaleway. ![npm bundle size](https://packagephobia.com/badge?p=@scaleway/cookie-consent) ![npm](https://img.shields.io/npm/v/@scaleway/cookie-consent) - - [`@scaleway/countries`](./packages_deprecated/countries/README.md): ISO 3166/3166-2 coutries JSON database. ![npm](https://img.shields.io/npm/dm/@scaleway/countries) @@ -104,6 +103,12 @@ scaleway-lib is a set of NPM packages used at Scaleway. ![npm bundle size](https://packagephobia.com/badge?p=@scaleway/regex) ![npm](https://img.shields.io/npm/v/@scaleway/regex) +- [`@scaleway/fuzzy-search`](./packages/fuzzy-search/README.md): fuzzy search utility + + ![npm](https://img.shields.io/npm/dm/@scaleway/fuzzy-search) + ![npm bundle size](https://packagephobia.com/badge?p=@scaleway/fuzzy-search) + ![npm](https://img.shields.io/npm/v/@scaleway/fuzzy-search) + - [`@scaleway/jest-helpers`](./packages/jest-helpers/README.md): utilities jest functions. ![npm](https://img.shields.io/npm/dm/@scaleway/jest-helpers) diff --git a/packages/fuzzy-search/.eslintignore b/packages/fuzzy-search/.eslintignore new file mode 100644 index 000000000..3a0b347c0 --- /dev/null +++ b/packages/fuzzy-search/.eslintignore @@ -0,0 +1,4 @@ +dist/ +coverage/ +node_modules +.reports/ diff --git a/packages/fuzzy-search/.npmignore b/packages/fuzzy-search/.npmignore new file mode 100644 index 000000000..d1811b877 --- /dev/null +++ b/packages/fuzzy-search/.npmignore @@ -0,0 +1,3 @@ +**/__tests__/** +src +!.npmignore diff --git a/packages/fuzzy-search/CHANGELOG.md b/packages/fuzzy-search/CHANGELOG.md new file mode 100644 index 000000000..420e6f23d --- /dev/null +++ b/packages/fuzzy-search/CHANGELOG.md @@ -0,0 +1 @@ +# Change Log diff --git a/packages/fuzzy-search/README.md b/packages/fuzzy-search/README.md new file mode 100644 index 000000000..cf623d65a --- /dev/null +++ b/packages/fuzzy-search/README.md @@ -0,0 +1,19 @@ +# `@scaleway/fuzzy-search` + +A fuzzy search utility + +--- + +## Install + +```bash +$ pnpm add @scaleway/fuzzy-search +``` + +## Usage + +```js +import { isFuzzyMatch } from "@scaleway/fuzzy-search"; + +const match = isFuzzyMatch("test", "tests"); +``` diff --git a/packages/fuzzy-search/package.json b/packages/fuzzy-search/package.json new file mode 100644 index 000000000..576862894 --- /dev/null +++ b/packages/fuzzy-search/package.json @@ -0,0 +1,39 @@ +{ + "name": "@scaleway/fuzzy-search", + "version": "0.0.1", + "description": "A small utility to use fuzzy search", + "type": "module", + "engines": { + "node": ">=20.x" + }, + "main": "./dist/index.cjs", + "sideEffects": false, + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "prebuild": "shx rm -rf dist", + "typecheck": "tsc --noEmit", + "type:generate": "tsc --declaration -p tsconfig.build.json", + "build": "vite build --config vite.config.ts && pnpm run type:generate", + "build:profile": "npx vite-bundle-visualizer -c vite.config.ts", + "lint": "eslint --report-unused-disable-directives --cache --cache-strategy content --ext ts,tsx .", + "test:unit": "vitest --run --config vite.config.ts", + "test:unit:coverage": "pnpm test:unit --coverage" + }, + "repository": { + "type": "git", + "url": "https://github.com/scaleway/scaleway-lib", + "directory": "packages/fuzzy-search" + }, + "license": "MIT" +} diff --git a/packages/fuzzy-search/src/__tests__/index.test.tsx b/packages/fuzzy-search/src/__tests__/index.test.tsx new file mode 100644 index 000000000..99b28105d --- /dev/null +++ b/packages/fuzzy-search/src/__tests__/index.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'vitest' +import { isFuzzyMatch, levenshteinDistance, normalizeString } from ".." + +describe('fuzzySearch', () => { + describe('normalizeString', () => { + test('returns correct string', () => { + expect(normalizeString('île-de-France')).toBe('ile de france') + }) + }) + + describe('levenshteinDistance', () => { + test('returns correct lenvenshtein distance', () => { + expect(levenshteinDistance('test', 'test')).toBe(0) + + expect(levenshteinDistance('tests', 'test')).toBe(1) + expect(levenshteinDistance('test', 'tests')).toBe(1) + + expect(levenshteinDistance('tset', 'test')).toBe(2) + + expect(levenshteinDistance('hello', 'test')).toBe(4) + + expect(levenshteinDistance('', 'test')).toBe(4) + expect(levenshteinDistance('test', '0')).toBe(4) + }) + }) + describe('fuzzySearch', () => { + test('with default distance (1)', () => { + expect(isFuzzyMatch('test', 'test')).toBeTruthy() + expect(isFuzzyMatch('tests', 'test')).toBeFalsy() + expect(isFuzzyMatch('test', 'tests')).toBeTruthy() + expect(isFuzzyMatch('tset', 'test')).toBeFalsy() + expect(isFuzzyMatch('hello', 'test')).toBeFalsy() + expect(isFuzzyMatch('', 'test')).toBeTruthy() + }) + + test('with distance = 0 (exact match)', () => { + expect(isFuzzyMatch('test', 'test', 0)).toBeTruthy() + expect(isFuzzyMatch('tests', 'test', 0)).toBeFalsy() + expect(isFuzzyMatch('test', 'tests', 0)).toBeTruthy() + expect(isFuzzyMatch('tset', 'test', 0)).toBeFalsy() + expect(isFuzzyMatch('hello', 'test', 0)).toBeFalsy() + expect(isFuzzyMatch('', 'test')).toBeTruthy() + }) + + test('with distance = 2 (swap tolerant)', () => { + expect(isFuzzyMatch('test', 'test', 2)).toBeTruthy() + expect(isFuzzyMatch('tests', 'test', 2)).toBeFalsy() + expect(isFuzzyMatch('test', 'tests', 2)).toBeTruthy() + expect(isFuzzyMatch('tset', 'test', 2)).toBeTruthy() + expect(isFuzzyMatch('hello', 'test', 2)).toBeFalsy() + expect(isFuzzyMatch('', 'test')).toBeTruthy() + }) + }) +}) diff --git a/packages/fuzzy-search/src/index.ts b/packages/fuzzy-search/src/index.ts new file mode 100644 index 000000000..86f98030c --- /dev/null +++ b/packages/fuzzy-search/src/index.ts @@ -0,0 +1,69 @@ +// Remove accent & uppercase +export const normalizeString = (string: string) => + string + .normalize('NFD') + .replace(/[\u0300-\u036F]/g, '') + .replace(/-/g, ' ') + .toLowerCase() + +export const levenshteinDistance = (query: string, slice: string): number => { + if (query.length === 0) { + return slice.length + } + if (slice.length === 0) { + return query.length + } + const distancesArray: number[][] = [] + for (let i = 0; i <= slice.length; i += 1) { + distancesArray[i] = [i] + for (let j = 1; j <= query.length; j += 1) { + const prev = distancesArray[i - 1] ?? [] + const curr = distancesArray[i] ?? [] + curr[j] = + i === 0 + ? j + : Math.min( + (prev[j] ?? 0) + 1, + (curr[j - 1] ?? 0) + 1, + (prev[j - 1] ?? 0) + (query[j - 1] === slice[i - 1] ? 0 : 1), + ) + } + } + + return distancesArray[slice.length]?.[query.length] ?? 0 +} + +/** + * Return `true` if there is a fuzz match in a substring + * By default, allow distance of 1 (which mean, 1 character difference for a match) + * @example isFuzzyMatch("merr", "mercury") = true + * isFuzzyMatch("cry", "mercury") = true + * isFuzzyMatch("mrcury", "mercury") = true + * isFuzzyMatch("mrecury", "mercury") = false + * isFuzzyMatch("mrecury", "mercury", 2) = true + */ +export const isFuzzyMatch = ( + query: string, + target: string, + maxDistance = 1, +): boolean => { + const normQuery = normalizeString(query) + const normTarget = normalizeString(target) + + if (normQuery.length === 0) { + return true + } + if (normQuery.length > normTarget.length) { + return false + } + + for (let i = 0; i <= normTarget.length - normQuery.length; i += 1) { + const slice = normTarget.slice(i, i + normQuery.length) + const dist = levenshteinDistance(normQuery, slice) + if (dist <= maxDistance) { + return true + } + } + + return false +} diff --git a/packages/fuzzy-search/tsconfig.build.json b/packages/fuzzy-search/tsconfig.build.json new file mode 100644 index 000000000..744c16721 --- /dev/null +++ b/packages/fuzzy-search/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist" + }, + "exclude": [ + "*.config.ts", + "*.setup.ts", + "**/__tests__", + "**/__mocks__", + "src/**/*.test.tsx" + ] +} diff --git a/packages/fuzzy-search/tsconfig.json b/packages/fuzzy-search/tsconfig.json new file mode 100644 index 000000000..7c2b0759a --- /dev/null +++ b/packages/fuzzy-search/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts", "src/**/*.tsx", "*.config.ts"] +} diff --git a/packages/fuzzy-search/vite.config.ts b/packages/fuzzy-search/vite.config.ts new file mode 100644 index 000000000..f4467e5ff --- /dev/null +++ b/packages/fuzzy-search/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig, mergeConfig } from 'vite' +import { defaultConfig } from '../../vite.config' +import { defaultConfig as vitestDefaultConfig } from '../../vitest.config' + +const config = { + ...defineConfig(defaultConfig), + ...vitestDefaultConfig, +} + +export default mergeConfig(config, { + build: { + target: ['node20'], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9dae579c2..b9a6440a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,6 +425,8 @@ importers: specifier: 'catalog:' version: 5.9.2 + packages/fuzzy-search: {} + packages/outdated-browser: {} packages/random-name: {}