├── .npmignore ├── .czrc ├── .gitignore ├── tsconfig.lint.json ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── fuzzyjs.yml ├── ava.config.js ├── .vscode ├── extensions.json └── settings.json ├── src ├── index.ts ├── utils │ ├── toLatin.ts │ ├── __tests__ │ │ ├── isLeading.test.ts │ │ └── range.test.ts │ ├── range.ts │ ├── isLeading.ts │ └── prepare.ts ├── score │ ├── defaultStrategy.ts │ └── __tests__ │ │ └── defaultStrategy.test.ts ├── surround.ts ├── test.ts ├── __tests__ │ ├── filter.test.ts │ ├── sort.test.ts │ ├── surround.test.ts │ ├── test.test.ts │ └── match.test.ts ├── array.ts └── match.ts ├── scripts ├── clean.ts └── build.ts ├── .cspell.json ├── tsconfig.json ├── benchmark ├── withoutTimers.js └── index.js ├── .eslintrc.json ├── .releaserc.json ├── esbuild-hook.js ├── CONTRIBUTING.md ├── package.json ├── CODE_OF_CONDUCT.md └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .nyc_output/ 3 | coverage/ 4 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "./node_modules/cz-conventional-changelog" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ 3 | .nyc_output/ 4 | coverage/ 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /tsconfig.lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [] 4 | } 5 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Fixes # 2 | 3 | ## Description of the changes 4 | 5 | - 6 | - 7 | - 8 | -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | files: ["src/**/__tests__/*.test.ts"], 3 | extensions: ["ts"], 4 | nodeArguments: ["-r", "./esbuild-hook"], 5 | }; 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "eamodio.gitlens", 6 | "streetsidesoftware.code-spell-checker" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: GitHub Discussions 4 | url: https://github.com/gjuchault/typescript-library-starter/discussions 5 | about: Please discuss non bug-related topics there 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { test, TestOptions } from "./test"; 2 | export { match, MatchRange, ScoreContext, ScoreStrategy } from "./match"; 3 | export { filter, sort, FilterOptions, SortOptions } from "./array"; 4 | export { surround, SurroundOptions } from "./surround"; 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // only use words from .cspell.json 3 | "cSpell.userWords": [], 4 | "cSpell.enabled": true, 5 | "editor.formatOnSave": true, 6 | "typescript.tsdk": "node_modules/typescript/lib", 7 | "typescript.enablePromptUseWorkspaceTsdk": true 8 | } 9 | -------------------------------------------------------------------------------- /scripts/clean.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs/promises"; 2 | import path from "path"; 3 | 4 | async function main() { 5 | await Promise.all([rmrf("build"), rmrf("coverage"), rmrf(".nyc_output")]); 6 | } 7 | 8 | async function rmrf(pathFromRoot: string): Promise { 9 | await fs.rm(path.join(__dirname, "../", pathFromRoot), { 10 | recursive: true, 11 | force: true, 12 | }); 13 | } 14 | 15 | if (require.main === module) { 16 | main(); 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/toLatin.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a normalized version of the string. This comes from 3 | * https://stackoverflow.com/a/37511463. It converts accented characters into 4 | * two UTF-8 characters (ie. `è` becomes e and `) and strip the accents. 5 | * 6 | * @param str The input string 7 | * @returns The input string without accents 8 | */ 9 | export function toLatin(str: string): string { 10 | return str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); 11 | } 12 | -------------------------------------------------------------------------------- /.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1", 3 | "language": "en", 4 | "words": [ 5 | "esbuild", 6 | "rmrf", 7 | "fuzzyjs", 8 | "socio", 9 | "octocat", 10 | "gjuchault", 11 | "ssjs", 12 | "ssjav", 13 | "yntax", 14 | "apbaca", 15 | "appbancar" 16 | ], 17 | "flagWords": [], 18 | "ignorePaths": [ 19 | "package.json", 20 | "package-lock.json", 21 | "yarn.lock", 22 | "tsconfig.json", 23 | "node_modules/**" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src/**/*.ts"], 3 | "exclude": ["./src/**/__tests__"], 4 | "compilerOptions": { 5 | "lib": ["es2020"], 6 | "module": "commonjs", 7 | "target": "es2020", 8 | 9 | "rootDir": "./src", 10 | "outDir": "build", 11 | 12 | "strict": true, 13 | "sourceMap": true, 14 | "esModuleInterop": true, 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "declaration": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /benchmark/withoutTimers.js: -------------------------------------------------------------------------------- 1 | const { test, match, sort: fuzzySort, filter: fuzzyFilter } = require('../dist/index.umd') 2 | 3 | let cities = require('./cities.json') 4 | 5 | cities = cities.map((city, i) => ({ id: i, ...city })) 6 | 7 | cities.sort(fuzzySort('ah', { sourcePath: 'city' })) 8 | 9 | cities.sort(fuzzySort('ah', { sourcePath: 'city', idPath: 'id' })) 10 | 11 | cities.filter(fuzzyFilter('ah', { sourcePath: 'city' })) 12 | 13 | match('av', cities[0].city, { withRanges: true, withScore: true }) 14 | 15 | test('av', cities[0].city) 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { "project": "./tsconfig.lint.json" }, 9 | "plugins": ["import", "@typescript-eslint"], 10 | "ignorePatterns": [ 11 | "scripts/*", 12 | "benchmark/*", 13 | "ava.config.js", 14 | "esbuild-hook.js" 15 | ], 16 | "extends": [ 17 | "eslint:recommended", 18 | "plugin:eslint-comments/recommended", 19 | "plugin:@typescript-eslint/recommended", 20 | "plugin:import/typescript", 21 | "prettier" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "+([0-9])?(.{+([0-9]),x}).x", 4 | "main", 5 | "master", 6 | "next", 7 | "next-major", 8 | { 9 | "name": "beta", 10 | "prerelease": true 11 | }, 12 | { 13 | "name": "alpha", 14 | "prerelease": true 15 | } 16 | ], 17 | "repositoryUrl": "https://github.com/gjuchault/fuzzyjs.git", 18 | "plugins": [ 19 | "@semantic-release/commit-analyzer", 20 | "@semantic-release/release-notes-generator", 21 | "@semantic-release/changelog", 22 | "@semantic-release/npm", 23 | "@semantic-release/github" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/__tests__/isLeading.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { isLeading } from "../isLeading"; 3 | 4 | test("given a leading character", (t) => { 5 | t.is(isLeading("f", "L"), true, "it returns true"); 6 | t.is(isLeading("f", "É"), true, "it returns true"); 7 | }); 8 | 9 | test("given a non-leading character", (t) => { 10 | t.is(isLeading("f", "f"), false, "it returns false"); 11 | t.is(isLeading("f", " "), false, "it returns false"); 12 | t.is(isLeading("f", "-"), false, "it returns false"); 13 | }); 14 | 15 | test("given a character following a separator", (t) => { 16 | t.is(isLeading("-", "f"), true, "it returns true"); 17 | }); 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. 16 | 2. 17 | 3. 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Desktop (please complete the following information):** 26 | - OS: [e.g. Ubuntu 22.04, macOS 11.4] 27 | - Node version [e.g 16.4.2] 28 | - Code Version [e.g. 1.1.0] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { build as esbuild } from "esbuild"; 3 | 4 | const baseConfig = { 5 | platform: "node" as const, 6 | target: "esnext" as const, 7 | format: "cjs" as const, 8 | bundle: true, 9 | nodePaths: [path.join(__dirname, "../src")], 10 | sourcemap: true, 11 | external: [], 12 | }; 13 | 14 | async function main() { 15 | await esbuild({ 16 | ...baseConfig, 17 | outdir: path.join(__dirname, "../build/cjs"), 18 | entryPoints: [path.join(__dirname, "../src/index.ts")], 19 | }); 20 | 21 | await esbuild({ 22 | ...baseConfig, 23 | format: "esm", 24 | outdir: path.join(__dirname, "../build/esm"), 25 | entryPoints: [path.join(__dirname, "../src/index.ts")], 26 | }); 27 | } 28 | 29 | if (require.main === module) { 30 | main(); 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/range.ts: -------------------------------------------------------------------------------- 1 | import { MatchRange } from "../match"; 2 | 3 | /** 4 | * Appends to an actual list of ranges a new match. This will only increment 5 | * the last [[MatchRange]] if the actual match and the last match were 6 | * siblings. 7 | * 8 | * @param ranges The previous ranges array 9 | * @param sourcePos The position in source that matched 10 | * @returns The new ranges array 11 | */ 12 | export function pushRange( 13 | ranges: MatchRange[], 14 | sourcePos: number 15 | ): MatchRange[] { 16 | const lastRange = ranges[ranges.length - 1]; 17 | 18 | if (lastRange && lastRange.stop === sourcePos) { 19 | return [ 20 | ...ranges.slice(0, -1), 21 | { 22 | start: lastRange.start, 23 | stop: sourcePos + 1, 24 | }, 25 | ]; 26 | } else { 27 | return [...ranges, { start: sourcePos, stop: sourcePos + 1 }]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/__tests__/range.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { pushRange } from "../range"; 3 | 4 | test("given an empty match range and a position", (t) => { 5 | const source = []; 6 | const result = pushRange(source, 5); 7 | 8 | t.deepEqual(result, [{ start: 5, stop: 6 }], "it pushes the position"); 9 | }); 10 | 11 | test("given a match range and a position", (t) => { 12 | const source = [{ start: 0, stop: 1 }]; 13 | const result = pushRange(source, 5); 14 | 15 | t.deepEqual( 16 | result, 17 | [ 18 | { start: 0, stop: 1 }, 19 | { start: 5, stop: 6 }, 20 | ], 21 | "it pushes the position" 22 | ); 23 | }); 24 | 25 | test("given a match range nearby and a position", (t) => { 26 | const source = pushRange([], 4); 27 | const result = pushRange(source, 5); 28 | 29 | t.deepEqual( 30 | result, 31 | [{ start: 4, stop: 6 }], 32 | "it pushes the previous position's stop property" 33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /src/utils/isLeading.ts: -------------------------------------------------------------------------------- 1 | import { toLatin } from "./toLatin"; 2 | 3 | /** 4 | * Returns true when the character is leading; ie. when it's a capital or 5 | * when it's following a separator character. You might also want to test if 6 | * the character comes from an alphabet as you wouldn't want to consider a 7 | * space as a leading character. 8 | * 9 | * @param prevChar The character that appears before `char` 10 | * @param char The actual character you want to test 11 | * @returns Wether or not the character is leading 12 | */ 13 | export function isLeading(prevChar: string, char: string): boolean { 14 | const precededBySeparator = 15 | prevChar === "-" || 16 | prevChar === "_" || 17 | prevChar === " " || 18 | prevChar === "." || 19 | prevChar === "/" || 20 | prevChar === "\\"; 21 | 22 | const isCharLeading = char.toUpperCase() === char && /\w/.test(toLatin(char)); 23 | 24 | return precededBySeparator || isCharLeading; 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/fuzzyjs.yml: -------------------------------------------------------------------------------- 1 | name: fuzzyjs 2 | 3 | on: [push] 4 | 5 | env: 6 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 7 | 8 | jobs: 9 | fuzzyjs: 10 | runs-on: ubuntu-latest 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.head_ref }} 14 | cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: volta-cli/action@v3 19 | - run: yarn --frozen-lockfile 20 | 21 | - name: Build 22 | run: yarn build 23 | 24 | - name: Format check 25 | run: yarn format:check 26 | 27 | - name: Lint check 28 | run: yarn lint:check 29 | 30 | - name: Spell check 31 | run: yarn spell:check 32 | 33 | - name: Test 34 | run: yarn test 35 | 36 | - name: Release 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | run: yarn semantic-release 41 | -------------------------------------------------------------------------------- /src/score/defaultStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ScoreContext } from "../match"; 2 | 3 | /** 4 | * Increments a context's score based on the context's values 5 | * This default strategy is based on 6 | * https://www.forrestthewoods.com/blog/reverse_engineering_sublime_texts_fuzzy_match/ 7 | * A fuzzy matching scoring function should most of the time push a big score 8 | * when matching a leading letter (ie. a letter that is capital or comes 9 | * after a separator). 10 | * 11 | * @param previousContext The last context given to pushScore. undefined if first match 12 | * @param context The actual context 13 | * @returns The new score 14 | */ 15 | export function pushScore( 16 | previousContext: ScoreContext | undefined, 17 | context: ScoreContext 18 | ): number { 19 | if (!context) { 20 | throw new TypeError("Expecting context to be defined"); 21 | } 22 | 23 | if (!context.match) { 24 | return context.currentScore - 1; 25 | } 26 | 27 | let increment = 0; 28 | 29 | if (previousContext && previousContext.match) { 30 | increment += 5; 31 | } 32 | 33 | if (context.leading) { 34 | increment += 10; 35 | } 36 | 37 | return context.currentScore + increment; 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/prepare.ts: -------------------------------------------------------------------------------- 1 | import { TestOptions } from "../test"; 2 | 3 | /** 4 | * This functions is used to throw when query or source is not defined as well 5 | * as normalizing and lower casing the input strings. 6 | * 7 | * @param query The fuzzy query string 8 | * @param source The fuzzy source string 9 | * @param opts An options object that can contains `caseSensitive` 10 | * @returns The reshaped query string and the reshaped source string. 11 | */ 12 | export function reshapeInput( 13 | query: string, 14 | source: string, 15 | opts: TestOptions 16 | ): [string, string] { 17 | if (typeof query !== "string") { 18 | throw new TypeError("Expecting query to be a string"); 19 | } 20 | 21 | if (typeof source !== "string") { 22 | throw new TypeError("Expecting source to be a string"); 23 | } 24 | 25 | let reshapedQuery = query.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); 26 | let reshapedSource = source.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); 27 | 28 | if (!opts.caseSensitive) { 29 | reshapedQuery = reshapedQuery.toLowerCase(); 30 | reshapedSource = reshapedSource.toLowerCase(); 31 | } 32 | 33 | return [reshapedQuery, reshapedSource]; 34 | } 35 | -------------------------------------------------------------------------------- /src/surround.ts: -------------------------------------------------------------------------------- 1 | import { MatchRange } from "./match"; 2 | 3 | export interface SurroundOptions { 4 | result: { 5 | ranges: MatchRange[]; 6 | }; 7 | prefix?: string; 8 | suffix?: string; 9 | } 10 | 11 | /** 12 | * Surround parts of the string that matched with prefix and suffix. 13 | * Useful to emphasize the parts that matched. 14 | */ 15 | export function surround(source: string, options: SurroundOptions): string { 16 | if (typeof source !== "string") { 17 | throw new TypeError("Expecting source to be a string"); 18 | } 19 | 20 | if (source.length === 0) { 21 | return ""; 22 | } 23 | 24 | if (!options?.result?.ranges?.length) { 25 | return source; 26 | } 27 | 28 | let result = source; 29 | let accumulator = 0; 30 | 31 | for (const range of options.result.ranges) { 32 | result = insertAt(result, range.start + accumulator, options.prefix); 33 | 34 | accumulator += (options.prefix ?? "").length; 35 | 36 | result = insertAt(result, range.stop + accumulator, options.suffix); 37 | 38 | accumulator += (options.suffix ?? "").length; 39 | } 40 | 41 | return result; 42 | } 43 | 44 | function insertAt(input: string, index: number, patch = ""): string { 45 | return input.slice(0, index) + patch + input.slice(index); 46 | } 47 | -------------------------------------------------------------------------------- /esbuild-hook.js: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | const Module = require("module"); 4 | const { transformSync } = require("esbuild"); 5 | const sourceMapSupport = require("source-map-support"); 6 | 7 | const cache = {}; 8 | 9 | function esbuildHook(code, filepath) { 10 | const result = transformSync(code, { 11 | target: "node16", 12 | sourcemap: "both", 13 | loader: "ts", 14 | format: "cjs", 15 | sourcefile: filepath, 16 | }); 17 | 18 | cache[filepath] = { 19 | url: filepath, 20 | code: result.code, 21 | map: result.map, 22 | }; 23 | 24 | return result.code; 25 | } 26 | 27 | sourceMapSupport.install({ 28 | environment: "node", 29 | retrieveFile(pathOrUrl) { 30 | const file = cache[pathOrUrl]; 31 | if (file) { 32 | return file.code; 33 | } else { 34 | return ""; 35 | } 36 | }, 37 | }); 38 | 39 | const defaultLoader = Module._extensions[".js"]; 40 | 41 | Module._extensions[".ts"] = function (mod, filename) { 42 | if (filename.includes("node_modules")) { 43 | return defaultLoader(mod, filename); 44 | } 45 | 46 | const defaultCompile = mod._compile; 47 | mod._compile = function (code) { 48 | mod._compile = defaultCompile; 49 | return mod._compile(esbuildHook(code, filename), filename); 50 | }; 51 | 52 | defaultLoader(mod, filename); 53 | }; 54 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | import { reshapeInput } from "./utils/prepare"; 2 | 3 | /** 4 | * This represents test options. You can specify if the source string should be 5 | * lower cased or not (wether you want the test to be case-sensitive or not). 6 | */ 7 | export interface TestOptions { 8 | caseSensitive?: boolean; 9 | } 10 | 11 | /** 12 | * Returns wether or not the query fuzzy matches the source. This will returns 13 | * a boolean. 14 | * 15 | * @param query The input query 16 | * @param source The input source 17 | * @param opts Options as defined by [[TestOptions]] 18 | * @returns Wether or not the query fuzzy matches the source 19 | */ 20 | export function test( 21 | query: string, 22 | source: string, 23 | opts: TestOptions = {} 24 | ): boolean { 25 | const [reshapedQuery, reshapedSource] = reshapeInput(query, source, opts); 26 | 27 | // if no source, then only return true if query is also empty 28 | if (!reshapedSource.length) { 29 | return !query.length; 30 | } 31 | 32 | if (!reshapedQuery.length) { 33 | return true; 34 | } 35 | 36 | // a bigger query than source will always return false 37 | if (reshapedQuery.length > reshapedSource.length) { 38 | return false; 39 | } 40 | 41 | let queryPos = 0; 42 | let sourcePos = 0; 43 | 44 | // loop on source string 45 | while (sourcePos < source.length) { 46 | const actualSourceCharacter = reshapedSource[sourcePos]; 47 | const queryCharacterWaitingForMatch = reshapedQuery[queryPos]; 48 | 49 | // if actual query character matches source character 50 | if (actualSourceCharacter === queryCharacterWaitingForMatch) { 51 | // move query pos 52 | queryPos += 1; 53 | } 54 | 55 | sourcePos += 1; 56 | } 57 | 58 | return queryPos === reshapedQuery.length; 59 | } 60 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | _Pull requests, bug reports, and all other forms of contribution are welcomed and highly encouraged!_ :octocat: 4 | 5 | ### Contents 6 | 7 | - [Code of Conduct](#book-code-of-conduct) 8 | - [Asking Questions](#bulb-asking-questions) 9 | - [How can I Contribute?](#inbox_tray-how-can-i-contribute) 10 | 11 | > **This guide serves to set clear expectations for everyone involved with the project so that we can improve it together while also creating a welcoming space for everyone to participate. Following these guidelines will help ensure a positive experience for contributors and maintainers.** 12 | 13 | ## :book: Code of Conduct 14 | 15 | Please review our [Code of Conduct](./CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. 16 | 17 | ## :bulb: Asking Questions 18 | 19 | If you have any question that does not relate to a bug or a feature request, please use [GitHub Discussions](https://github.com/gjuchault/typescript-library-starter/discussions) instead of GitHub issues. 20 | 21 | ## :inbox_tray: How can I Contribute? 22 | 23 | **GitHub issues** 24 | 25 | If you encounter a problem with this library or if you have a new feature you'd like to see in this project, please create [a new issue](https://github.com/gjuchault/typescript-library-starter/issues/new/choose). 26 | 27 | **GitHub Pull requests** 28 | 29 | Please leverage the repository's own tools to make sure the code is aligned with our standards: 30 | 31 | 1. Run all check commands before submitting the PR (`type:check`, `format:check`, `lint:check`, `test:coverage` and `spell:check`) 32 | 2. Please commit your changes and run a `setup` command so you can actually check how would the template look like once cleaned up 33 | 3. Always leverage the `cz` command to create a commit. We heavily rely on this for automatic releases. 34 | -------------------------------------------------------------------------------- /src/__tests__/filter.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { filter } from "../array"; 3 | 4 | test("given a query", (t) => { 5 | const sources = [ 6 | "Set Syntax: JavaScript", 7 | "Set Syntax: CSS", 8 | "Set Syntax: HTML", 9 | ]; 10 | 11 | t.deepEqual( 12 | sources.filter(filter("ssjs", { iterator: (item) => item })), 13 | [sources[0]], 14 | "it returns a Array.prototype compatible callback" 15 | ); 16 | t.deepEqual( 17 | sources.filter( 18 | filter("ssjs", { iterator: (item) => item, caseSensitive: true }) 19 | ), 20 | [], 21 | "it returns a Array.prototype compatible callback" 22 | ); 23 | t.deepEqual( 24 | sources.filter( 25 | filter("SSJS", { iterator: (item) => item, caseSensitive: true }) 26 | ), 27 | [sources[0]], 28 | "it returns a Array.prototype compatible callback" 29 | ); 30 | }); 31 | 32 | test("given a query and a sourceAccessor", (t) => { 33 | const sources = [ 34 | { foo: { name: "Set Syntax: JavaScript" } }, 35 | { foo: { name: "Set Syntax: CSS" } }, 36 | { foo: { name: "Set Syntax: HTML" } }, 37 | ]; 38 | 39 | t.deepEqual( 40 | sources.filter(filter("ssjs", { iterator: (source) => source.foo.name })), 41 | [sources[0]], 42 | "it returns an Array.prototype compatible callback" 43 | ); 44 | t.deepEqual( 45 | sources.filter( 46 | filter("ssjs", { 47 | caseSensitive: true, 48 | iterator: (source) => source.foo.name, 49 | }) 50 | ), 51 | [], 52 | "it returns an Array.prototype compatible callback" 53 | ); 54 | t.deepEqual( 55 | sources.filter( 56 | filter("SSJS", { 57 | caseSensitive: true, 58 | iterator: (source) => source.foo.name, 59 | }) 60 | ), 61 | [sources[0]], 62 | "it returns an Array.prototype compatible callback" 63 | ); 64 | }); 65 | -------------------------------------------------------------------------------- /src/score/__tests__/defaultStrategy.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable eslint-comments/disable-enable-pair */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | 4 | import test from "ava"; 5 | import { pushScore } from "../defaultStrategy"; 6 | 7 | test("given an invalid context", (t) => { 8 | t.throws( 9 | () => pushScore(undefined as any, undefined as any), 10 | { instanceOf: TypeError }, 11 | "it throws a TypeError" 12 | ); 13 | t.throws( 14 | () => pushScore(null, null as any), 15 | { instanceOf: TypeError }, 16 | "it throws a TypeError" 17 | ); 18 | }); 19 | 20 | test("given a non-matching context", (t) => { 21 | t.is( 22 | pushScore(null, { 23 | currentScore: 0, 24 | match: false, 25 | character: "f", 26 | leading: false, 27 | }), 28 | -1, 29 | "it returns a decreased score" 30 | ); 31 | }); 32 | 33 | test("given a matching context", (t) => { 34 | t.is( 35 | pushScore(null, { 36 | currentScore: 0, 37 | match: true, 38 | character: "f", 39 | leading: false, 40 | }), 41 | 0, 42 | "it returns the same score" 43 | ); 44 | }); 45 | 46 | test("given a matching leading context", (t) => { 47 | t.is( 48 | pushScore(null, { 49 | currentScore: 0, 50 | match: true, 51 | character: "F", 52 | leading: true, 53 | }), 54 | 10, 55 | "it returns an increased score" 56 | ); 57 | }); 58 | 59 | test("given a previous context with a consecutive match", (t) => { 60 | t.is( 61 | pushScore( 62 | { 63 | currentScore: 0, 64 | match: true, 65 | character: "f", 66 | leading: false, 67 | }, 68 | { 69 | currentScore: 0, 70 | match: true, 71 | character: "f", 72 | leading: false, 73 | } 74 | ), 75 | 5, 76 | "it returns an increased score" 77 | ); 78 | }); 79 | -------------------------------------------------------------------------------- /src/__tests__/sort.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { sort } from "../array"; 3 | 4 | test("given a query and no idAccessor", (t) => { 5 | const sources = [ 6 | "Set Syntax: HTML", 7 | "Set Syntax: css", 8 | "Set Syntax: JavaScript", 9 | ]; 10 | 11 | t.deepEqual( 12 | sources.sort(sort("sss", { iterator: (item) => item })), 13 | ["Set Syntax: JavaScript", "Set Syntax: css", "Set Syntax: HTML"], 14 | "should return an Array.prototype compatible callback, non optimized" 15 | ); 16 | }); 17 | 18 | test("given a query and an idAccessor options", (t) => { 19 | const sources = [ 20 | { id: "Set Syntax: Rust" }, 21 | { id: "Set Syntax: HTML" }, 22 | { id: "Set Syntax: css" }, 23 | { id: "Set Syntax: JavaScript" }, 24 | { id: "Set Syntax: YAML" }, 25 | { id: "Set Syntax: C++" }, 26 | { id: "Set Syntax: Diff" }, 27 | { id: "Set Syntax: Rust" }, 28 | ]; 29 | 30 | t.deepEqual( 31 | sources.sort(sort("sss", { iterator: (source) => source.id })), 32 | [ 33 | { id: "Set Syntax: JavaScript" }, 34 | { id: "Set Syntax: css" }, 35 | { id: "Set Syntax: Rust" }, 36 | { id: "Set Syntax: Rust" }, 37 | { id: "Set Syntax: HTML" }, 38 | { id: "Set Syntax: YAML" }, 39 | { id: "Set Syntax: C++" }, 40 | { id: "Set Syntax: Diff" }, 41 | ], 42 | "should return an Array.prototype compatible callback, optimized" 43 | ); 44 | 45 | t.deepEqual( 46 | sources.sort( 47 | sort("sss", { 48 | iterator: (source) => source.id, 49 | }) 50 | ), 51 | [ 52 | { id: "Set Syntax: JavaScript" }, 53 | { id: "Set Syntax: css" }, 54 | { id: "Set Syntax: Rust" }, 55 | { id: "Set Syntax: Rust" }, 56 | { id: "Set Syntax: HTML" }, 57 | { id: "Set Syntax: YAML" }, 58 | { id: "Set Syntax: C++" }, 59 | { id: "Set Syntax: Diff" }, 60 | ], 61 | "should return an Array.prototype compatible callback, optimized" 62 | ); 63 | }); 64 | -------------------------------------------------------------------------------- /benchmark/index.js: -------------------------------------------------------------------------------- 1 | const Benchmark = require("benchmark"); 2 | 3 | const { 4 | test, 5 | match, 6 | sort: fuzzySort, 7 | filter: fuzzyFilter, 8 | } = require("../build/cjs"); 9 | 10 | process.stdout.write("Setting up benmarks..."); 11 | 12 | let cities = require("./cities.json"); 13 | 14 | cities = cities.map((city, i) => ({ id: i, ...city })); 15 | 16 | console.log(" Done."); 17 | 18 | const benchmarkUnoptimizedSort = new Benchmark("unoptimized sort", () => { 19 | cities.sort(fuzzySort("ah", { sourceAccessor: (city) => city.city })); 20 | }); 21 | 22 | const benchmarkOptimizedSort = new Benchmark("optimized sort", () => { 23 | cities.sort( 24 | fuzzySort("ah", { 25 | sourceAccessor: (city) => city.city, 26 | idAccessor: (city) => city.id, 27 | }) 28 | ); 29 | }); 30 | 31 | const benchmarkFilter = new Benchmark("filter", () => { 32 | cities.filter(fuzzyFilter("ah", { sourceAccessor: (city) => city.city })); 33 | }); 34 | 35 | const benchmarkMatch = new Benchmark("match", () => { 36 | match("av", cities[0].city, { withRanges: true, withScore: true }); 37 | }); 38 | 39 | const benchmarkTest = new Benchmark("test", () => { 40 | test("av", cities[0].city); 41 | }); 42 | 43 | process.stdout.write(`Running unoptimized sort on ${cities.length} objects...`); 44 | benchmarkUnoptimizedSort.run(); 45 | console.log(" Done."); 46 | 47 | process.stdout.write(`Running optimized sort on ${cities.length} objects...`); 48 | benchmarkOptimizedSort.run(); 49 | console.log(" Done."); 50 | 51 | process.stdout.write(`Running filter on ${cities.length} objects...`); 52 | benchmarkFilter.run(); 53 | console.log(" Done."); 54 | 55 | process.stdout.write(`Running match...`); 56 | benchmarkMatch.run(); 57 | console.log(" Done."); 58 | 59 | process.stdout.write(`Running test...`); 60 | benchmarkTest.run(); 61 | console.log(" Done."); 62 | 63 | console.log(""); 64 | 65 | console.log("Results:"); 66 | console.log(benchmarkUnoptimizedSort.toString()); 67 | console.log(benchmarkOptimizedSort.toString()); 68 | console.log(benchmarkFilter.toString()); 69 | console.log(benchmarkMatch.toString()); 70 | console.log(benchmarkTest.toString()); 71 | -------------------------------------------------------------------------------- /src/array.ts: -------------------------------------------------------------------------------- 1 | import { test, TestOptions } from "./test"; 2 | import { match } from "./match"; 3 | 4 | export type ItemIterator = (source: TItem) => string; 5 | 6 | export interface FilterOptions extends TestOptions { 7 | iterator: ItemIterator; 8 | } 9 | 10 | export interface SortOptions extends TestOptions { 11 | iterator: ItemIterator; 12 | } 13 | 14 | type FilterIterator = (item: TItem) => boolean; 15 | type SortIterator = (leftItem: TItem, rightItem: TItem) => number; 16 | 17 | /** 18 | * This array helper can be used as an `Array.prototype.filter` callback as it 19 | * will return true or false when passing it a source string. 20 | */ 21 | export function filter( 22 | query: string, 23 | options: FilterOptions 24 | ): FilterIterator { 25 | return function (item) { 26 | const source = options.iterator(item); 27 | return test(query, source, options); 28 | }; 29 | } 30 | 31 | /** 32 | * This array helper can be used as an `Array.prototype.sort` callback as it 33 | * will return `-1`/`0`/`1` when passing it two source strings. 34 | */ 35 | export function sort( 36 | query: string, 37 | options: SortOptions 38 | ): SortIterator { 39 | const cacheMap: Map = new Map(); 40 | 41 | return (leftItem, rightItem) => { 42 | const leftSource = options.iterator(leftItem); 43 | const rightSource = options.iterator(rightItem); 44 | 45 | const cachedLeftMatch = cacheMap.get(leftSource); 46 | const cachedRightMatch = cacheMap.get(rightSource); 47 | 48 | const leftScore = cachedLeftMatch 49 | ? cachedLeftMatch 50 | : match(query, leftSource, { 51 | withScore: true, 52 | caseSensitive: options.caseSensitive, 53 | }).score; 54 | 55 | const rightScore = cachedRightMatch 56 | ? cachedRightMatch 57 | : match(query, rightSource, { 58 | withScore: true, 59 | caseSensitive: options.caseSensitive, 60 | }).score; 61 | 62 | if (!cacheMap.has(leftSource)) { 63 | cacheMap.set(leftSource, leftScore); 64 | } 65 | 66 | if (!cacheMap.has(rightSource)) { 67 | cacheMap.set(rightSource, rightScore); 68 | } 69 | 70 | if (rightScore === leftScore) { 71 | return 0; 72 | } 73 | 74 | return rightScore > leftScore ? 1 : -1; 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /src/__tests__/surround.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable eslint-comments/disable-enable-pair */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | 4 | import test from "ava"; 5 | import { surround } from "../surround"; 6 | 7 | test("given an invalid source", (t) => { 8 | t.throws( 9 | () => surround(1 as any, null as any), 10 | { instanceOf: TypeError }, 11 | "it throws a TypeError" 12 | ); 13 | t.throws( 14 | () => surround(null as any, null as any), 15 | { 16 | instanceOf: TypeError, 17 | }, 18 | "it throws a TypeError" 19 | ); 20 | }); 21 | 22 | test("given an empty source", (t) => { 23 | t.is( 24 | surround("", null as any), 25 | "", 26 | "it returns an empty result whatever the options are" 27 | ); 28 | t.is( 29 | surround("", {} as any), 30 | "", 31 | "it returns an empty result whatever the options are" 32 | ); 33 | }); 34 | 35 | test("given an invalid result", (t) => { 36 | t.is(surround("foo", null as any), "foo", "it returns the input"); 37 | t.is(surround("foo", { result: null as any }), "foo", "it returns the input"); 38 | }); 39 | 40 | test("given an empty range set", (t) => { 41 | t.is( 42 | surround("foo", { result: {} as any, prefix: "<" }), 43 | "foo", 44 | "it returns the input" 45 | ); 46 | t.is( 47 | surround("foo", { result: { ranges: [] }, prefix: "<" }), 48 | "foo", 49 | "it returns the input" 50 | ); 51 | }); 52 | 53 | test("given a source and a range set", (t) => { 54 | t.is( 55 | surround("foo bar", { 56 | result: { 57 | ranges: [ 58 | { start: 0, stop: 1 }, 59 | { start: 4, stop: 6 }, 60 | ], 61 | }, 62 | prefix: "", 63 | suffix: "", 64 | }), 65 | "foo bar", 66 | "it returns the surrounded result" 67 | ); 68 | 69 | t.is( 70 | surround("foo bar", { 71 | result: { 72 | ranges: [ 73 | { start: 0, stop: 1 }, 74 | { start: 4, stop: 6 }, 75 | ], 76 | }, 77 | prefix: "*", 78 | }), 79 | "*foo *bar", 80 | "it returns the surrounded result" 81 | ); 82 | 83 | t.is( 84 | surround("foo bar", { 85 | result: { 86 | ranges: [ 87 | { start: 0, stop: 1 }, 88 | { start: 4, stop: 6 }, 89 | ], 90 | }, 91 | suffix: "*", 92 | }), 93 | "f*oo ba*r", 94 | "it returns the surrounded result" 95 | ); 96 | }); 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fuzzyjs", 3 | "version": "0.0.0-development", 4 | "description": "Simple fuzzy matching", 5 | "keywords": [ 6 | "fuzzy", 7 | "searching", 8 | "matching", 9 | "algorithm", 10 | "fuzz", 11 | "sort", 12 | "sorting" 13 | ], 14 | "homepage": "https://github.com/gjuchault/fuzzyjs", 15 | "bugs": "https://github.com/gjuchault/fuzzyjs/issues", 16 | "author": "Gabriel Juchault ", 17 | "repository": "gjuchault/fuzzyjs", 18 | "main": "./build/cjs/index.js", 19 | "module": "./build/esm/index.js", 20 | "types": "./build/index.d.ts", 21 | "license": "MIT", 22 | "engines": { 23 | "node": ">=8.0.0" 24 | }, 25 | "publishConfig": { 26 | "access": "public" 27 | }, 28 | "scripts": { 29 | "build": "yarn clean && yarn type:dts && yarn type:build", 30 | "clean": "node -r ./esbuild-hook ./scripts/clean", 31 | "type:dts": "tsc --emitDeclarationOnly", 32 | "type:check": "tsc --noEmit", 33 | "type:build": "node -r ./esbuild-hook ./scripts/build", 34 | "format": "prettier \"src/**/*.ts\" --write", 35 | "format:check": "prettier \"src/**/*.ts\" --check", 36 | "lint": "eslint src --ext .ts --fix", 37 | "lint:check": "eslint src --ext .ts", 38 | "test": "ava", 39 | "test:coverage": "nyc ava && nyc report --reporter=html", 40 | "spell:check": "cspell \"{README.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md,.github/*.md,src/**/*.ts}\"", 41 | "cz": "cz", 42 | "semantic-release": "semantic-release" 43 | }, 44 | "devDependencies": { 45 | "@semantic-release/changelog": "^5.0.1", 46 | "@semantic-release/commit-analyzer": "^8.0.1", 47 | "@semantic-release/github": "^7.2.3", 48 | "@semantic-release/npm": "^7.1.3", 49 | "@semantic-release/release-notes-generator": "^9.0.3", 50 | "@types/node": "^16.0.0", 51 | "@types/prompts": "^2.0.13", 52 | "@typescript-eslint/eslint-plugin": "^4.28.2", 53 | "@typescript-eslint/parser": "^4.28.2", 54 | "ava": "^3.15.0", 55 | "benchmark": "^2.1.4", 56 | "commitizen": "^4.2.4", 57 | "cspell": "^5.6.6", 58 | "cz-conventional-changelog": "^3.3.0", 59 | "esbuild": "^0.12.15", 60 | "eslint": "^7.30.0", 61 | "eslint-config-prettier": "^8.3.0", 62 | "eslint-plugin-eslint-comments": "^3.2.0", 63 | "eslint-plugin-import": "^2.23.4", 64 | "nyc": "^15.1.0", 65 | "prettier": "^2.3.2", 66 | "semantic-release": "^19.0.3", 67 | "source-map-support": "^0.5.19", 68 | "typescript": "^4.3.5" 69 | }, 70 | "volta": { 71 | "node": "16.4.1", 72 | "yarn": "1.22.10", 73 | "npm": "7.19.1" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/__tests__/test.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable eslint-comments/disable-enable-pair */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | 4 | import test from "ava"; 5 | import { test as fuzzyjsTest } from "../test"; 6 | 7 | test("given an invalid query", (t) => { 8 | t.throws( 9 | () => fuzzyjsTest(null as any, "foo"), 10 | { instanceOf: TypeError }, 11 | "it throws a TypeError" 12 | ); 13 | t.throws( 14 | () => fuzzyjsTest(1 as any, "foo"), 15 | { instanceOf: TypeError }, 16 | "it throws a TypeError" 17 | ); 18 | t.throws( 19 | () => fuzzyjsTest(NaN as any, "foo"), 20 | { instanceOf: TypeError }, 21 | "it throws a TypeError" 22 | ); 23 | t.throws( 24 | () => fuzzyjsTest(undefined as any, "foo"), 25 | { instanceOf: TypeError }, 26 | "it throws a TypeError" 27 | ); 28 | 29 | t.throws( 30 | () => fuzzyjsTest("foo", null as any), 31 | { instanceOf: TypeError }, 32 | "it throws a TypeError" 33 | ); 34 | t.throws( 35 | () => fuzzyjsTest("foo", 1 as any), 36 | { instanceOf: TypeError }, 37 | "it throws a TypeError" 38 | ); 39 | t.throws( 40 | () => fuzzyjsTest("foo", NaN as any), 41 | { instanceOf: TypeError }, 42 | "it throws a TypeError" 43 | ); 44 | t.throws( 45 | () => fuzzyjsTest("foo", undefined as any), 46 | { instanceOf: TypeError }, 47 | "it throws a TypeError" 48 | ); 49 | }); 50 | 51 | test("given no query", (t) => { 52 | t.is(fuzzyjsTest("", "anything"), true, "it returns true"); 53 | }); 54 | 55 | test("given no source provided", (t) => { 56 | t.is(fuzzyjsTest("foo", ""), false, "it returns false"); 57 | t.is(fuzzyjsTest("", ""), true, "it unless both strings are empty"); 58 | }); 59 | 60 | test("given a query bigger than source", (t) => { 61 | t.is(fuzzyjsTest("foo", "f"), false, "it returns false"); 62 | }); 63 | 64 | test("given a query matching the source", (t) => { 65 | t.is(fuzzyjsTest("abc", "Apple Banana Caramel"), true, "it returns true"); 66 | t.is(fuzzyjsTest("abc", "apple banana caramel"), true, "it returns true"); 67 | t.is(fuzzyjsTest("abc", "abc"), true, "it returns true"); 68 | }); 69 | 70 | test("given a query with caseSensitive option", (t) => { 71 | t.is( 72 | fuzzyjsTest("abc", "apple banana caramel", { caseSensitive: true }), 73 | true, 74 | "it returns true when the query matches the source" 75 | ); 76 | 77 | t.is( 78 | fuzzyjsTest("abc", "Apple Banana Caramel", { caseSensitive: true }), 79 | false, 80 | "it returns false otherwise" 81 | ); 82 | }); 83 | 84 | test("given a non-ASCII query and an ASCII source", (t) => { 85 | t.is( 86 | fuzzyjsTest("föÔ", "foo"), 87 | true, 88 | "it returns true when the query matches the source" 89 | ); 90 | 91 | t.is(fuzzyjsTest("föÔ", "foz"), false, "it returns false otherwise"); 92 | }); 93 | 94 | test("given an ASCII query and a non-ASCII source", (t) => { 95 | t.is( 96 | fuzzyjsTest("foo", "föÔ"), 97 | true, 98 | "it returns true when the query matches the source" 99 | ); 100 | 101 | t.is(fuzzyjsTest("foz", "föÔ"), false, "it returns false otherwise"); 102 | }); 103 | 104 | test("given a non-ASCII query and a non-ASCII source", (t) => { 105 | t.is( 106 | fuzzyjsTest("fôö", "föô"), 107 | true, 108 | "it returns true when the query matches the source" 109 | ); 110 | }); 111 | 112 | test("Issue #28 - given caseSensitive to true and a non-ASCII query", (t) => { 113 | t.is( 114 | fuzzyjsTest("e", "é", { caseSensitive: true }), 115 | true, 116 | "should match when the query matches the source" 117 | ); 118 | }); 119 | -------------------------------------------------------------------------------- /src/match.ts: -------------------------------------------------------------------------------- 1 | import { reshapeInput } from "./utils/prepare"; 2 | import { pushRange } from "./utils/range"; 3 | import { pushScore } from "./score/defaultStrategy"; 4 | import { isLeading } from "./utils/isLeading"; 5 | 6 | /** 7 | * This represents a Range that you can get if you call match with `withRanges` 8 | * set to true. It is composed of indexes of the source string that are matched 9 | * by the input string. 10 | */ 11 | export interface MatchRange { 12 | start: number; 13 | stop: number; 14 | } 15 | 16 | /** 17 | * This represents a score context that the scoring function will use to 18 | * compute the new score. It must include: 19 | * - `currentScore` the actual score (ie. the result of the last `pushScore` call or 0) 20 | * - `character` the actual source character. It must not be reshaped (ie. lower-cased or normalized) 21 | * - `match` wether or not the actual source character is matched by the query 22 | * - `leading` wether or not the actual source character is a leading character (as returned by the `isLeading` function) 23 | */ 24 | export interface ScoreContext { 25 | currentScore: number; 26 | character: string; 27 | match: boolean; 28 | leading: boolean; 29 | } 30 | 31 | /** 32 | * This represents the signature of the `pushScore` function. It requires the 33 | * previous context as long as the actual one (as we want to check for 34 | * concurrent matches), and returns the new score as a number. 35 | * 36 | * The scoring function is not returning a number from 0 to 1 but a whole 37 | * natural number. 38 | */ 39 | export type ScoreStrategy = ( 40 | previousContext: ScoreContext | null, 41 | context: ScoreContext 42 | ) => number; 43 | 44 | /** 45 | * Returns wether or not the query fuzzy matches the source 46 | */ 47 | export function match( 48 | query: string, 49 | source: string 50 | ): { match: boolean; ranges: MatchRange[]; score: number }; 51 | export function match( 52 | query: string, 53 | source: string, 54 | opts: { caseSensitive?: boolean } 55 | ): { match: boolean; ranges: MatchRange[]; score: number }; 56 | export function match( 57 | query: string, 58 | source: string, 59 | opts: { withScore?: true; caseSensitive?: boolean } 60 | ): { match: boolean; ranges: MatchRange[]; score: number }; 61 | export function match( 62 | query: string, 63 | source: string, 64 | opts: { withScore?: false; caseSensitive?: boolean } 65 | ): { match: boolean; ranges: MatchRange[] }; 66 | export function match( 67 | query: string, 68 | source: string, 69 | opts: { 70 | withScore?: boolean; 71 | caseSensitive?: boolean; 72 | } = { withScore: true } 73 | ): { match: boolean; score?: number; ranges: MatchRange[] } { 74 | const [reshapedQuery, reshapedSource] = reshapeInput(query, source, opts); 75 | 76 | const withScore = !(opts?.withScore === false); 77 | 78 | // if no source, then only return true if query is also empty 79 | if (reshapedSource.length === 0 || reshapedQuery.length === 0) { 80 | return { 81 | match: query.length === 0, 82 | ranges: 83 | query.length === 0 ? [{ start: 0, stop: reshapedSource.length }] : [], 84 | score: withScore ? (query.length === 0 ? 1 : 0) : undefined, 85 | }; 86 | } 87 | 88 | // a bigger query than source will always return false 89 | if (reshapedQuery.length > reshapedSource.length) { 90 | return { match: false, ranges: [], score: withScore ? 0 : undefined }; 91 | } 92 | 93 | let queryPos = 0; 94 | let sourcePos = 0; 95 | let score = 0; 96 | let lastContext: ScoreContext | undefined; 97 | let ranges: MatchRange[] = []; 98 | 99 | // loop on source string 100 | while (sourcePos < source.length) { 101 | const actualSourceCharacter = reshapedSource[sourcePos]; 102 | const queryCharacterWaitingForMatch = reshapedQuery[queryPos]; 103 | const match = actualSourceCharacter === queryCharacterWaitingForMatch; 104 | 105 | if (withScore) { 106 | // context does not use reshaped as uppercase changes score 107 | const previousCharacter = sourcePos > 0 ? source[sourcePos - 1] : ""; 108 | 109 | const newContext: ScoreContext = { 110 | currentScore: score, 111 | character: source[sourcePos], 112 | match, 113 | leading: isLeading(previousCharacter, source[sourcePos]), 114 | }; 115 | 116 | score = pushScore(lastContext, newContext); 117 | 118 | lastContext = newContext; 119 | } 120 | 121 | // if actual query character matches source character 122 | if (match) { 123 | // push range to result 124 | ranges = pushRange(ranges, sourcePos); 125 | 126 | // move query pos 127 | queryPos += 1; 128 | } 129 | 130 | sourcePos += 1; 131 | } 132 | 133 | if (queryPos === reshapedQuery.length) { 134 | return { 135 | match: true, 136 | ranges, 137 | score: withScore ? score : undefined, 138 | }; 139 | } 140 | 141 | return { 142 | match: false, 143 | ranges: [], 144 | score: withScore ? 0 : undefined, 145 | }; 146 | } 147 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | gabriel.juchault@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fuzzyjs 2 | 3 | ![NPM](https://img.shields.io/npm/l/@gjuchault/typescript-library-starter) 4 | ![NPM](https://img.shields.io/npm/v/@gjuchault/typescript-library-starter) 5 | ![GitHub Workflow Status](https://github.com/gjuchault/typescript-library-starter/actions/workflows/typescript-library-starter.yml/badge.svg?branch=main) 6 | 7 | fuzzyjs is a fuzzy search algorithm in javascript. 8 | 9 | ## Usage 10 | 11 | **`test`** 12 | 13 | Tests a query against a source using fuzzy matching 14 | 15 | ```ts 16 | import { test } from "fuzzyjs"; 17 | 18 | test("ssjs", "Set Syntax: JavaScript"); 19 | true; 20 | ``` 21 | 22 | ```ts 23 | function test(query: string, source: string, opts?: TestOptions): boolean; 24 | 25 | type TestOptions = { 26 | caseSensitive?: boolean; // (default: false) 27 | }; 28 | ``` 29 | 30 | **`match`** 31 | 32 | Matches a query against a source using fuzzy matching, returns information about the result 33 | 34 | ```ts 35 | import { match } from 'fuzzyjs' 36 | 37 | match('ssjav', 'Set Syntax: JavaScript') 38 | { 39 | match: true, 40 | score: 22, 41 | ranges: [ 42 | { start: 0, stop: 1 }, 43 | { start: 4, stop: 5 }, 44 | { start: 12, stop: 15 } 45 | ] 46 | } 47 | ``` 48 | 49 | ```ts 50 | function match(query: string, source: string, opts?: MatchOptions): MatchResult; 51 | 52 | type MatchOptions = { 53 | caseSensitive?: boolean; 54 | strategy?: ScoreStrategy; // (default: defaultStrategy, see below) 55 | withScore?: boolean; // (default: true) 56 | }; 57 | 58 | type MatchResult = { 59 | match: boolean; 60 | score: number; // only if `withScore` is true, else undefined 61 | ranges: MatchRange[]; 62 | }; 63 | ``` 64 | 65 | ## Utilities 66 | 67 | **`surround`** 68 | 69 | Surround parts of the string that matched with prefix and suffix 70 | 71 | ```ts 72 | import { match, surround } from "fuzzyjs"; 73 | 74 | const result = match("ssjav", "Set Syntax: JavaScript"); 75 | 76 | surround("Set Syntax: JavaScript", { 77 | result, 78 | prefix: "", 79 | suffix: "", 80 | }); 81 | ("Set Syntax: JavaScript"); 82 | ``` 83 | 84 | ```ts 85 | function surround(source: string, options: SurroundOptions): string; 86 | 87 | type SurroundOptions = { 88 | result: { 89 | ranges: MatchRange[]; 90 | }; 91 | prefix?: string; // (default: '') 92 | suffix?: string; // (default: '') 93 | }; 94 | ``` 95 | 96 | **`filter`** 97 | 98 | Can be used as a [Array.prototype.filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter) callback. 99 | 100 | ```ts 101 | import { filter as fuzzy } from "fuzzyjs"; 102 | 103 | const sources = [ 104 | "Set Syntax: JavaScript", 105 | "Set Syntax: CSS", 106 | "Set Syntax: HTML", 107 | ]; 108 | 109 | sources.filter(fuzzy("ssjs", { iterator: (item) => item })); 110 | ["Set Syntax: JavaScript"]; 111 | 112 | const sources = [ 113 | { name: { foo: "Set Syntax: JavaScript" } }, 114 | { name: { foo: "Set Syntax: CSS" } }, 115 | { name: { foo: "Set Syntax: HTML" } }, 116 | ]; 117 | 118 | sources.filter(fuzzy("ssjs", { iterator: (source) => source.name.foo })); 119 | [{ name: { foo: "Set Syntax: JavaScript" } }]; 120 | ``` 121 | 122 | ```ts 123 | function filter( 124 | query: string, 125 | options: FilterOptions 126 | ): (source: TItem) => boolean; 127 | 128 | type FilterOptions = { 129 | caseSensitive?: boolean; 130 | iterator: (source: TItem) => string; 131 | }; 132 | ``` 133 | 134 | **`sort`** 135 | 136 | Can be used as a [Array.prototype.sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) callback. 137 | If you have a large array of objects, you might want to pass `idAccessor` as it creates a [memoization](https://en.wikipedia.org/wiki/Memoization) table which reduces drastically how many times the fuzzy matching algorithm will be called. 138 | 139 | ```ts 140 | import { sort as fuzzy } from "fuzzyjs"; 141 | 142 | const sources = [ 143 | "Set Syntax: CSS", 144 | "Set Syntax: HTML", 145 | "Set Syntax: JavaScript", 146 | ]; 147 | 148 | sources.sort(fuzzy("ssjs", { iterator: (item) => item })); 149 | [("Set Syntax: JavaScript", "Set Syntax: CSS", "Set Syntax: HTML")]; 150 | 151 | const sources = [ 152 | { name: { id: 0, foo: "Set Syntax: CSS" } }, 153 | { name: { id: 1, foo: "Set Syntax: HTML" } }, 154 | { name: { id: 2, foo: "Set Syntax: JavaScript" } }, 155 | ]; 156 | 157 | sources.sort(fuzzy("ssjs", { iterator: (source) => source.name.foo })); 158 | [ 159 | { name: { id: 2, foo: "Set Syntax: JavaScript" } }, 160 | { name: { id: 0, foo: "Set Syntax: CSS" } }, 161 | { name: { id: 1, foo: "Set Syntax: HTML" } }, 162 | ]; 163 | ``` 164 | 165 | ```ts 166 | function sort( 167 | query: string, 168 | options?: SortOptions 169 | ): (leftSource: TItem, rightSource: TItem) => 0 | 1 | -1; 170 | 171 | type SortOptions = { 172 | caseSensitive?: boolean; 173 | iterator: (item: TItem) => string; 174 | }; 175 | ``` 176 | 177 | ## Scoring function 178 | 179 | A scoring function is a function that given two context, returns a number (either positive or negative) that will be added the the match score. 180 | 181 | A leading character is a character that matters more than others. 182 | These are made of capitals and letters following `-_ ./\`. 183 | 184 | ```ts 185 | function pushScore( 186 | previousContext: ScoreContext | undefined, 187 | context: ScoreContext 188 | ): number; 189 | 190 | type ScoreContext = { 191 | currentScore: number; // the current match score 192 | character: string; // the current character 193 | match: boolean; // is the character matching the source string 194 | leading: boolean; // is the character leading 195 | }; 196 | ``` 197 | 198 | Link to default strategy: [here](./src/score/defaultStrategy.ts). 199 | 200 | ## License 201 | 202 | fuzzyjs is licensed under MIT License. 203 | -------------------------------------------------------------------------------- /src/__tests__/match.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable eslint-comments/disable-enable-pair */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | 4 | import test from "ava"; 5 | import { match } from "../match"; 6 | 7 | test("given an invalid query", (t) => { 8 | t.throws( 9 | () => match(null as any, "foo"), 10 | { instanceOf: TypeError }, 11 | "throws a TypeError" 12 | ); 13 | t.throws( 14 | () => match(1 as any, "foo"), 15 | { instanceOf: TypeError }, 16 | "throws a TypeError" 17 | ); 18 | t.throws( 19 | () => match(NaN as any, "foo"), 20 | { instanceOf: TypeError }, 21 | "throws a TypeError" 22 | ); 23 | t.throws( 24 | () => match(undefined as any, "foo"), 25 | { instanceOf: TypeError }, 26 | "throws a TypeError" 27 | ); 28 | 29 | t.throws( 30 | () => match("foo", null as any), 31 | { instanceOf: TypeError }, 32 | "throws a TypeError" 33 | ); 34 | t.throws( 35 | () => match("foo", 1 as any), 36 | { instanceOf: TypeError }, 37 | "throws a TypeError" 38 | ); 39 | t.throws( 40 | () => match("foo", NaN as any), 41 | { instanceOf: TypeError }, 42 | "throws a TypeError" 43 | ); 44 | t.throws( 45 | () => match("foo", undefined as any), 46 | { instanceOf: TypeError }, 47 | "throws a TypeError" 48 | ); 49 | }); 50 | 51 | test("given no query", (t) => { 52 | t.deepEqual( 53 | match("", "anything"), 54 | { match: true, ranges: [{ start: 0, stop: 8 }], score: 1 }, 55 | "it returns a truthy match" 56 | ); 57 | 58 | t.like( 59 | match("", "anything", { withScore: false }), 60 | { match: true, ranges: [{ start: 0, stop: 8 }] }, 61 | "it returns a truthy match" 62 | ); 63 | }); 64 | 65 | test("given no source provided", (t) => { 66 | t.deepEqual( 67 | match("foo", ""), 68 | { match: false, ranges: [], score: 0 }, 69 | "it returns a falsy match" 70 | ); 71 | 72 | t.deepEqual( 73 | match("", ""), 74 | { match: true, ranges: [{ start: 0, stop: 0 }], score: 1 }, 75 | "unless the two strings are empty" 76 | ); 77 | }); 78 | 79 | test("given a query bigger than source", (t) => { 80 | t.deepEqual(match("foo", "f"), { match: false, ranges: [], score: 0 }); 81 | t.like(match("foo", "f", { withScore: false }), { 82 | match: false, 83 | ranges: [], 84 | }); 85 | }); 86 | 87 | test("given a query matching the source", (t) => { 88 | t.deepEqual( 89 | match("abc", "Apple Banana Caramel", { withScore: true }), 90 | { 91 | match: true, 92 | score: 13, 93 | ranges: [ 94 | { start: 0, stop: 1 }, 95 | { start: 6, stop: 7 }, 96 | { start: 13, stop: 14 }, 97 | ], 98 | }, 99 | "it returns a truthy match" 100 | ); 101 | t.deepEqual( 102 | match("abc", "apple banana caramel", { 103 | withScore: true, 104 | }), 105 | { 106 | match: true, 107 | score: 3, 108 | ranges: [ 109 | { start: 0, stop: 1 }, 110 | { start: 6, stop: 7 }, 111 | { start: 13, stop: 14 }, 112 | ], 113 | }, 114 | "it returns a truthy match" 115 | ); 116 | t.deepEqual( 117 | match("abc", "abc", { withScore: true }), 118 | { 119 | match: true, 120 | score: 10, 121 | ranges: [{ start: 0, stop: 3 }], 122 | }, 123 | "it returns a truthy match" 124 | ); 125 | t.like( 126 | match("abc", "Apple Banana Caramel", { withScore: false }), 127 | { 128 | match: true, 129 | ranges: [ 130 | { start: 0, stop: 1 }, 131 | { start: 6, stop: 7 }, 132 | { start: 13, stop: 14 }, 133 | ], 134 | }, 135 | "it returns a truthy match" 136 | ); 137 | 138 | t.deepEqual( 139 | match("a", "Apple Banana Caramel", { withScore: true }), 140 | { 141 | match: true, 142 | score: -9, 143 | ranges: [{ start: 0, stop: 1 }], 144 | }, 145 | "uses a correct scoring algorithm 1" 146 | ); 147 | t.deepEqual( 148 | match("ab", "Apple Banana Caramel", { withScore: true }), 149 | { 150 | match: true, 151 | score: 2, 152 | ranges: [ 153 | { start: 0, stop: 1 }, 154 | { start: 6, stop: 7 }, 155 | ], 156 | }, 157 | "uses a correct scoring algorithm 2" 158 | ); 159 | t.deepEqual( 160 | match("abc", "Apple Banana Caramel", { withScore: true }), 161 | { 162 | match: true, 163 | score: 13, 164 | ranges: [ 165 | { start: 0, stop: 1 }, 166 | { start: 6, stop: 7 }, 167 | { start: 13, stop: 14 }, 168 | ], 169 | }, 170 | "uses a correct scoring algorithm 3" 171 | ); 172 | t.deepEqual( 173 | match("apbaca", "Apple Banana Caramel", { withScore: true }), 174 | { 175 | match: true, 176 | score: 31, 177 | ranges: [ 178 | { start: 0, stop: 2 }, 179 | { start: 6, stop: 8 }, 180 | { start: 13, stop: 15 }, 181 | ], 182 | }, 183 | "uses a correct scoring algorithm 4" 184 | ); 185 | t.deepEqual( 186 | match("ap ba car", "Apple Banana Caramel", { withScore: true }), 187 | { 188 | match: true, 189 | score: 49, 190 | ranges: [ 191 | { start: 0, stop: 2 }, 192 | { start: 5, stop: 8 }, 193 | { start: 12, stop: 16 }, 194 | ], 195 | }, 196 | "uses a correct scoring algorithm 5" 197 | ); 198 | t.deepEqual( 199 | match("appbancar", "Apple Banana Caramel", { withScore: true }), 200 | { 201 | match: true, 202 | score: 49, 203 | ranges: [ 204 | { start: 0, stop: 3 }, 205 | { start: 6, stop: 9 }, 206 | { start: 13, stop: 16 }, 207 | ], 208 | }, 209 | "uses a correct scoring algorithm 6" 210 | ); 211 | }); 212 | 213 | test("given a query with caseSensitive option", (t) => { 214 | t.deepEqual( 215 | match("abc", "Apple Banana Caramel", { caseSensitive: true }), 216 | { 217 | match: false, 218 | ranges: [], 219 | score: 0, 220 | }, 221 | "it returns a truthy match when the query matches the source" 222 | ); 223 | 224 | t.deepEqual( 225 | match("abc", "apple banana caramel", { caseSensitive: true }), 226 | { 227 | match: true, 228 | ranges: [ 229 | { start: 0, stop: 1 }, 230 | { start: 6, stop: 7 }, 231 | { start: 13, stop: 14 }, 232 | ], 233 | score: 3, 234 | }, 235 | "it returns a falsy match otherwise" 236 | ); 237 | }); 238 | 239 | test("given a non-matching query", (t) => { 240 | t.deepEqual( 241 | match("abc", "foobar"), 242 | { match: false, ranges: [], score: 0 }, 243 | "it returns a falsy match" 244 | ); 245 | t.like( 246 | match("abc", "foobar", { withScore: false }), 247 | { 248 | match: false, 249 | ranges: [], 250 | }, 251 | "it returns a falsy match" 252 | ); 253 | }); 254 | --------------------------------------------------------------------------------