Comparing version 4.0.4 to 5.0.0
@@ -1,60 +0,70 @@ | ||
const Benchmark = require('benchmark') | ||
const Benchmark = require("benchmark"); | ||
const { test, match, sort: fuzzySort, filter: fuzzyFilter } = require('../dist/index.umd') | ||
const { | ||
test, | ||
match, | ||
sort: fuzzySort, | ||
filter: fuzzyFilter, | ||
} = require("../build/cjs"); | ||
process.stdout.write('Setting up benmarks...') | ||
process.stdout.write("Setting up benmarks..."); | ||
let cities = require('./cities.json') | ||
let cities = require("./cities.json"); | ||
cities = cities.map((city, i) => ({ id: i, ...city })) | ||
cities = cities.map((city, i) => ({ id: i, ...city })); | ||
console.log(' Done.') | ||
console.log(" Done."); | ||
const benchmarkUnoptimizedSort = new Benchmark('unoptimized sort', () => { | ||
cities.sort(fuzzySort('ah', { sourcePath: 'city' })) | ||
}) | ||
const benchmarkUnoptimizedSort = new Benchmark("unoptimized sort", () => { | ||
cities.sort(fuzzySort("ah", { sourceAccessor: (city) => city.city })); | ||
}); | ||
const benchmarkOptimizedSort = new Benchmark('optimized sort', () => { | ||
cities.sort(fuzzySort('ah', { sourcePath: 'city', idPath: 'id' })) | ||
}) | ||
const benchmarkOptimizedSort = new Benchmark("optimized sort", () => { | ||
cities.sort( | ||
fuzzySort("ah", { | ||
sourceAccessor: (city) => city.city, | ||
idAccessor: (city) => city.id, | ||
}) | ||
); | ||
}); | ||
const benchmarkFilter = new Benchmark('filter', () => { | ||
cities.filter(fuzzyFilter('ah', { sourcePath: 'city' })) | ||
}) | ||
const benchmarkFilter = new Benchmark("filter", () => { | ||
cities.filter(fuzzyFilter("ah", { sourceAccessor: (city) => city.city })); | ||
}); | ||
const benchmarkMatch = new Benchmark('match', () => { | ||
match('av', cities[0].city, { withRanges: true, withScore: true }) | ||
}) | ||
const benchmarkMatch = new Benchmark("match", () => { | ||
match("av", cities[0].city, { withRanges: true, withScore: true }); | ||
}); | ||
const benchmarkTest = new Benchmark('test', () => { | ||
test('av', cities[0].city) | ||
}) | ||
const benchmarkTest = new Benchmark("test", () => { | ||
test("av", cities[0].city); | ||
}); | ||
process.stdout.write(`Running unoptimized sort on ${cities.length} objects...`) | ||
benchmarkUnoptimizedSort.run() | ||
console.log(' Done.') | ||
process.stdout.write(`Running unoptimized sort on ${cities.length} objects...`); | ||
benchmarkUnoptimizedSort.run(); | ||
console.log(" Done."); | ||
process.stdout.write(`Running optimized sort on ${cities.length} objects...`) | ||
benchmarkOptimizedSort.run() | ||
console.log(' Done.') | ||
process.stdout.write(`Running optimized sort on ${cities.length} objects...`); | ||
benchmarkOptimizedSort.run(); | ||
console.log(" Done."); | ||
process.stdout.write(`Running filter on ${cities.length} objects...`) | ||
benchmarkFilter.run() | ||
console.log(' Done.') | ||
process.stdout.write(`Running filter on ${cities.length} objects...`); | ||
benchmarkFilter.run(); | ||
console.log(" Done."); | ||
process.stdout.write(`Running match...`) | ||
benchmarkMatch.run() | ||
console.log(' Done.') | ||
process.stdout.write(`Running match...`); | ||
benchmarkMatch.run(); | ||
console.log(" Done."); | ||
process.stdout.write(`Running test...`) | ||
benchmarkTest.run() | ||
console.log(' Done.') | ||
process.stdout.write(`Running test...`); | ||
benchmarkTest.run(); | ||
console.log(" Done."); | ||
console.log('') | ||
console.log(""); | ||
console.log('Results:') | ||
console.log(benchmarkUnoptimizedSort.toString()) | ||
console.log(benchmarkOptimizedSort.toString()) | ||
console.log(benchmarkFilter.toString()) | ||
console.log(benchmarkMatch.toString()) | ||
console.log(benchmarkTest.toString()) | ||
console.log("Results:"); | ||
console.log(benchmarkUnoptimizedSort.toString()); | ||
console.log(benchmarkOptimizedSort.toString()); | ||
console.log(benchmarkFilter.toString()); | ||
console.log(benchmarkMatch.toString()); | ||
console.log(benchmarkTest.toString()); |
151
package.json
{ | ||
"name": "fuzzyjs", | ||
"version": "4.0.4", | ||
"description": "Fuzzy matching in Javascript", | ||
"version": "5.0.0", | ||
"description": "Simple fuzzy matching", | ||
"keywords": [ | ||
@@ -14,10 +14,9 @@ "fuzzy", | ||
], | ||
"main": "dist/index.umd.js", | ||
"module": "dist/index.es5.js", | ||
"typings": "dist/types/index.d.ts", | ||
"homepage": "https://github.com/gjuchault/fuzzyjs", | ||
"bugs": "https://github.com/gjuchault/fuzzyjs/issues", | ||
"author": "Gabriel Juchault <gabriel.juchault@gmail.com>", | ||
"repository": { | ||
"type": "git", | ||
"url": "git@github.com:gjuchault/fuzzyjs.git" | ||
}, | ||
"repository": "gjuchault/fuzzyjs", | ||
"main": "./build/cjs/index.js", | ||
"module": "./build/esm/index.js", | ||
"types": "./build/index.d.ts", | ||
"license": "MIT", | ||
@@ -27,98 +26,52 @@ "engines": { | ||
}, | ||
"publishConfig": { | ||
"access": "public" | ||
}, | ||
"scripts": { | ||
"lint": "prettier --write \"{src,test}/**/*.ts\"", | ||
"prebuild": "rimraf dist", | ||
"build": "tsc --module commonjs && rollup -c rollup.config.ts && typedoc --out docs --target es6 --theme minimal --mode file src", | ||
"start": "rollup -c rollup.config.ts -w", | ||
"test": "jest --coverage", | ||
"test:watch": "jest --coverage --watch", | ||
"test:prod": "yarn lint && yarn test --no-cache", | ||
"report-coverage": "cat ./coverage/lcov.info | coveralls", | ||
"semantic-release": "semantic-release", | ||
"benchmark": "node benchmark", | ||
"benchmark:brk": "node --inspect --debug-brk benchmark/withoutTimers", | ||
"travis-deploy-once": "travis-deploy-once" | ||
"build": "yarn clean && yarn type:dts && yarn type:build", | ||
"clean": "node -r esbuild-register ./scripts/clean", | ||
"type:dts": "tsc --emitDeclarationOnly", | ||
"type:check": "tsc --noEmit", | ||
"type:build": "node -r esbuild-register ./scripts/build", | ||
"format": "prettier \"src/**/*.ts\" --write", | ||
"format:check": "prettier \"src/**/*.ts\" --check", | ||
"lint": "eslint src --ext .ts --fix", | ||
"lint:check": "eslint src --ext .ts", | ||
"test": "ava", | ||
"test:coverage": "nyc ava && nyc report --reporter=html", | ||
"spell:check": "cspell \"{README.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md,.github/*.md,src/**/*.ts}\"", | ||
"cz": "cz", | ||
"semantic-release": "semantic-release" | ||
}, | ||
"lint-staged": { | ||
"{src,test}/**/*.ts": [ | ||
"prettier --write", | ||
"git add" | ||
] | ||
}, | ||
"config": { | ||
"commitizen": { | ||
"path": "node_modules/cz-conventional-changelog" | ||
} | ||
}, | ||
"jest": { | ||
"transform": { | ||
".(ts|tsx)": "ts-jest" | ||
}, | ||
"testEnvironment": "node", | ||
"testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", | ||
"moduleFileExtensions": [ | ||
"ts", | ||
"tsx", | ||
"js" | ||
], | ||
"coveragePathIgnorePatterns": [ | ||
"/node_modules/", | ||
"/test/" | ||
], | ||
"coverageThreshold": { | ||
"global": { | ||
"branches": 90, | ||
"functions": 95, | ||
"lines": 95, | ||
"statements": 95 | ||
} | ||
}, | ||
"collectCoverageFrom": [ | ||
"src/**/*.{js,ts}" | ||
] | ||
}, | ||
"prettier": { | ||
"semi": false, | ||
"singleQuote": true, | ||
"parser": "typescript" | ||
}, | ||
"commitlint": { | ||
"extends": [ | ||
"@commitlint/config-conventional" | ||
] | ||
}, | ||
"devDependencies": { | ||
"@commitlint/cli": "^8.0.0", | ||
"@commitlint/config-conventional": "^8.0.0", | ||
"@types/benchmark": "^1.0.31", | ||
"@types/jest": "^25.1.4", | ||
"@types/node": "^13.1.1", | ||
"@semantic-release/changelog": "^5.0.1", | ||
"@semantic-release/commit-analyzer": "^8.0.1", | ||
"@semantic-release/github": "^7.2.3", | ||
"@semantic-release/npm": "^7.1.3", | ||
"@semantic-release/release-notes-generator": "^9.0.3", | ||
"@types/node": "^16.0.0", | ||
"@types/prompts": "^2.0.13", | ||
"@typescript-eslint/eslint-plugin": "^4.28.2", | ||
"@typescript-eslint/parser": "^4.28.2", | ||
"ava": "^3.15.0", | ||
"benchmark": "^2.1.4", | ||
"colors": "^1.3.2", | ||
"coveralls": "^3.0.2", | ||
"cz-conventional-changelog": "^3.1.0", | ||
"husky": "^4.2.3", | ||
"jest": "^25.1.0", | ||
"jest-config": "^25.1.0", | ||
"lint-staged": "^10.0.8", | ||
"lodash.camelcase": "^4.3.0", | ||
"prettier": "^1.14.3", | ||
"rimraf": "^3.0.0", | ||
"rollup": "^2.0.6", | ||
"rollup-plugin-commonjs": "^10.0.2", | ||
"rollup-plugin-json": "^4.0.0", | ||
"rollup-plugin-node-resolve": "^5.0.2", | ||
"rollup-plugin-sourcemaps": "^0.5.0", | ||
"rollup-plugin-typescript2": "^0.26.0", | ||
"ts-jest": "^25.2.1", | ||
"ts-node": "^8.3.0", | ||
"typedoc": "^0.16.11", | ||
"typescript": "^3.8.3" | ||
"commitizen": "^4.2.4", | ||
"cspell": "^5.6.6", | ||
"cz-conventional-changelog": "^3.3.0", | ||
"esbuild": "^0.12.15", | ||
"esbuild-register": "^2.6.0", | ||
"eslint": "^7.30.0", | ||
"eslint-config-prettier": "^8.3.0", | ||
"eslint-plugin-eslint-comments": "^3.2.0", | ||
"eslint-plugin-import": "^2.23.4", | ||
"nyc": "^15.1.0", | ||
"prettier": "^2.3.2", | ||
"semantic-release": "^17.4.4", | ||
"typescript": "^4.3.5" | ||
}, | ||
"dependencies": {}, | ||
"husky": { | ||
"hooks": { | ||
"pre-commit": "lint-staged" | ||
} | ||
"volta": { | ||
"node": "16.4.1", | ||
"yarn": "1.22.10", | ||
"npm": "7.19.1" | ||
} | ||
} |
181
README.md
# fuzzyjs | ||
[![Build Status](https://travis-ci.org/gjuchault/fuzzyjs.svg?branch=master)](https://travis-ci.org/gjuchault/fuzzyjs) | ||
[![Coverage Status](https://coveralls.io/repos/github/gjuchault/fuzzyjs/badge.svg?branch=master)](https://coveralls.io/github/gjuchault/fuzzyjs?branch=master) | ||
![NPM](https://img.shields.io/npm/l/@gjuchault/typescript-library-starter) | ||
![NPM](https://img.shields.io/npm/v/@gjuchault/typescript-library-starter) | ||
![GitHub Workflow Status](https://github.com/gjuchault/typescript-library-starter/actions/workflows/typescript-library-starter.yml/badge.svg?branch=master) | ||
@@ -15,14 +16,14 @@ fuzzyjs is a fuzzy search algorithm in javascript. | ||
```ts | ||
import { test } from 'fuzzyjs' | ||
import { test } from "fuzzyjs"; | ||
test('ssjs', 'Set Syntax: JavaScript') | ||
true | ||
test("ssjs", "Set Syntax: JavaScript"); | ||
true; | ||
``` | ||
```ts | ||
const test: (query: string, source: string, opts?: TestOptions) => boolean | ||
function test(query: string, source: string, opts?: TestOptions): boolean; | ||
type TestOptions = { | ||
caseSensitive: boolean // (default: false) | ||
} | ||
caseSensitive?: boolean; // (default: false) | ||
}; | ||
``` | ||
@@ -37,8 +38,6 @@ | ||
match('ssjs', 'Set Syntax: JavaScript') | ||
{ match: true, score: 22 } | ||
match('ssjav', 'Set Syntax: JavaScript', { withScore: false, withRanges: true }) | ||
match('ssjav', 'Set Syntax: JavaScript') | ||
{ | ||
match: true, | ||
score: 22, | ||
ranges: [ | ||
@@ -53,15 +52,15 @@ { start: 0, stop: 1 }, | ||
```ts | ||
const match: (query: string, source: string, opts?: MatchOptions) => MatchResult | ||
function match(query: string, source: string, opts?: MatchOptions): MatchResult; | ||
type MatchOptions = TestOptions & { | ||
strategy?: ScoreStrategy // (default: see below) | ||
withRanges?: boolean // (default: false) | ||
withScore?: boolean // (default: true) | ||
} | ||
type MatchOptions = { | ||
caseSensitive?: boolean; | ||
strategy?: ScoreStrategy; // (default: defaultStrategy, see below) | ||
withScore?: boolean; // (default: true) | ||
}; | ||
type MatchResult = { | ||
match: boolean | ||
score?: number | ||
ranges?: MatchRange[] | ||
} | ||
match: boolean; | ||
score: number; // only if `withScore` is true, else undefined | ||
ranges: MatchRange[]; | ||
}; | ||
``` | ||
@@ -76,27 +75,24 @@ | ||
```ts | ||
import { match, surround } from 'fuzzyjs' | ||
import { match, surround } from "fuzzyjs"; | ||
const result = match('ssjav', 'Set Syntax: JavaScript', { withRanges: true }) | ||
const result = match("ssjav", "Set Syntax: JavaScript"); | ||
surround( | ||
'Set Syntax: JavaScript', | ||
{ | ||
result, | ||
prefix: '<strong>', | ||
suffix: '</strong>' | ||
} | ||
) | ||
'<strong>S</strong>et <strong>S</strong>yntax: <strong>Jav</strong>aScript' | ||
surround("Set Syntax: JavaScript", { | ||
result, | ||
prefix: "<strong>", | ||
suffix: "</strong>", | ||
}); | ||
("<strong>S</strong>et <strong>S</strong>yntax: <strong>Jav</strong>aScript"); | ||
``` | ||
```ts | ||
const surround: (source: string, options: SurroundOptions) => string | ||
function surround(source: string, options: SurroundOptions): string; | ||
type SurroundOptions = { | ||
result: { | ||
ranges: MatchRange[] | ||
} | ||
prefix?: string // (default: '') | ||
suffix?: string // (default: '') | ||
} | ||
ranges: MatchRange[]; | ||
}; | ||
prefix?: string; // (default: '') | ||
suffix?: string; // (default: '') | ||
}; | ||
``` | ||
@@ -107,28 +103,35 @@ | ||
Can be used as a [Array.prototype.filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter) callback. | ||
You can use the `sourceAccessor` option if you pass an array of objects that contains the string you want to match. | ||
```ts | ||
import { filter as fuzzy } from 'fuzzyjs' | ||
import { filter as fuzzy } from "fuzzyjs"; | ||
const sources = ['Set Syntax: JavaScript', 'Set Syntax: CSS', 'Set Syntax: HTML'] | ||
const sources = [ | ||
"Set Syntax: JavaScript", | ||
"Set Syntax: CSS", | ||
"Set Syntax: HTML", | ||
]; | ||
sources.filter(fuzzy('ssjs')) | ||
[ 'Set Syntax: JavaScript' ] | ||
sources.filter(fuzzy("ssjs", { iterator: (item) => item })); | ||
["Set Syntax: JavaScript"]; | ||
const sources = [ | ||
{ name: { foo: 'Set Syntax: JavaScript' } }, | ||
{ name: { foo: 'Set Syntax: CSS' } }, | ||
{ name: { foo: 'Set Syntax: HTML' } } | ||
] | ||
{ name: { foo: "Set Syntax: JavaScript" } }, | ||
{ name: { foo: "Set Syntax: CSS" } }, | ||
{ name: { foo: "Set Syntax: HTML" } }, | ||
]; | ||
sources.filter(fuzzy('ssjs', { sourceAccessor: source => source.name.foo })) | ||
[ { name: { foo: 'Set Syntax: JavaScript' } } ] | ||
sources.filter(fuzzy("ssjs", { iterator: (source) => source.name.foo })); | ||
[{ name: { foo: "Set Syntax: JavaScript" } }]; | ||
``` | ||
```ts | ||
const filter: (query: string, options?: FilterOptions) => (source: any) => boolean | ||
function filter<TItem>( | ||
query: string, | ||
options: FilterOptions<TItem> | ||
): (source: TItem) => boolean; | ||
type FilterOptions = TestOptions & { | ||
sourceAccessor?: (source: any) => string | ||
} | ||
type FilterOptions<TItem> = { | ||
caseSensitive?: boolean; | ||
iterator: (source: TItem) => string; | ||
}; | ||
``` | ||
@@ -142,38 +145,37 @@ | ||
```ts | ||
import { sort as fuzzy } from 'fuzzyjs' | ||
import { sort as fuzzy } from "fuzzyjs"; | ||
const sources = ['Set Syntax: CSS', 'Set Syntax: HTML', 'Set Syntax: JavaScript'] | ||
const sources = [ | ||
"Set Syntax: CSS", | ||
"Set Syntax: HTML", | ||
"Set Syntax: JavaScript", | ||
]; | ||
sources.sort(fuzzy('ssjs')) | ||
[ 'Set Syntax: JavaScript', 'Set Syntax: CSS', 'Set Syntax: HTML' ] | ||
sources.sort(fuzzy("ssjs", { iterator: (item) => item })); | ||
[("Set Syntax: JavaScript", "Set Syntax: CSS", "Set Syntax: HTML")]; | ||
const sources = [ | ||
{ name: { id: 0, foo: 'Set Syntax: CSS' } }, | ||
{ name: { id: 1, foo: 'Set Syntax: HTML' } }, | ||
{ name: { id: 2, foo: 'Set Syntax: JavaScript' } } | ||
] | ||
{ name: { id: 0, foo: "Set Syntax: CSS" } }, | ||
{ name: { id: 1, foo: "Set Syntax: HTML" } }, | ||
{ name: { id: 2, foo: "Set Syntax: JavaScript" } }, | ||
]; | ||
sources.sort(fuzzy('ssjs', { sourceAccessor: source => source.name.foo })) | ||
sources.sort(fuzzy("ssjs", { iterator: (source) => source.name.foo })); | ||
[ | ||
{ name: { id: 2, foo: 'Set Syntax: JavaScript' } }, | ||
{ name: { id: 0, foo: 'Set Syntax: CSS' } }, | ||
{ name: { id: 1, foo: 'Set Syntax: HTML' } } | ||
] | ||
// same, but will be faster thanks to memoization | ||
sources.sort(fuzzy('ssjs', { sourceAccessor: source => source.name.foo, idAccessor: source => source.name.id })) | ||
[ | ||
{ name: { id: 2, foo: 'Set Syntax: JavaScript' } }, | ||
{ name: { id: 0, foo: 'Set Syntax: CSS' } }, | ||
{ name: { id: 1, foo: 'Set Syntax: HTML' } } | ||
] | ||
{ name: { id: 2, foo: "Set Syntax: JavaScript" } }, | ||
{ name: { id: 0, foo: "Set Syntax: CSS" } }, | ||
{ name: { id: 1, foo: "Set Syntax: HTML" } }, | ||
]; | ||
``` | ||
```ts | ||
const sort: (query: string, options?: SortOptions) => (leftSource: any, rightSource: any) => 0 | 1 | -1 | ||
function sort<TItem>( | ||
query: string, | ||
options?: SortOptions<TItem> | ||
): (leftSource: TItem, rightSource: TItem) => 0 | 1 | -1; | ||
type SortOptions = MatchOptions & { | ||
sourceAccessor?: Accessor // used as an accessor if array is made of objects | ||
idAccessor?: Accessor // used as an accessor if you want fuzzy to be memoized | ||
} | ||
type SortOptions<TItem> = { | ||
caseSensitive?: boolean; | ||
iterator: (item: TItem) => string; | ||
}; | ||
``` | ||
@@ -186,13 +188,16 @@ | ||
A leading character is a character that matters more than others. | ||
These are made of capitals and letters follwoing `-_ ./\`. | ||
These are made of capitals and letters following `-_ ./\`. | ||
```ts | ||
const pushScore: (previousContext: ScoreContext, context: ScoreContext) => number | ||
function pushScore( | ||
previousContext: ScoreContext | undefined, | ||
context: ScoreContext | ||
): number; | ||
type ScoreContext = { | ||
currentScore: number // the current match score | ||
character: string // the current character | ||
match: boolean // is the character matching the source string | ||
leading: boolean // is the character leading | ||
} | ||
currentScore: number; // the current match score | ||
character: string; // the current character | ||
match: boolean; // is the character matching the source string | ||
leading: boolean; // is the character leading | ||
}; | ||
``` | ||
@@ -204,2 +209,2 @@ | ||
fuzzyjs is licensed under MIT License. | ||
fuzzyjs is licensed under MIT License. |
131
src/array.ts
@@ -1,105 +0,76 @@ | ||
import { test, TestOptions } from './test' | ||
import { match, MatchOptions } from './match' | ||
import { test, TestOptions } from "./test"; | ||
import { match } from "./match"; | ||
/** | ||
* This represents an accessor as used with `sourceAccessor` and `idAccessor`. | ||
*/ | ||
export type Accessor<T> = (source: T) => string | ||
export type ItemIterator<TItem> = (source: TItem) => string; | ||
/** | ||
* This represents filter util options. It is based on [[TestOptions]] (as | ||
* filter is using test) and extends it with `sourceAccessor` when you filter | ||
* over an object array. | ||
*/ | ||
export interface FilterOptions<T> extends TestOptions { | ||
sourceAccessor?: Accessor<T> | ||
export interface FilterOptions<TItem> extends TestOptions { | ||
iterator: ItemIterator<TItem>; | ||
} | ||
/** | ||
* This represents sort util options. It is based on [[MatchOptions]] (as sort | ||
* is using match) and extends it with `sourceAccessor` when you filter over an | ||
* object array and `idAccessor` when you want to optimize the sort with | ||
* memoization. | ||
*/ | ||
export interface SortOptions<T> extends MatchOptions { | ||
sourceAccessor?: Accessor<T> | ||
idAccessor?: Accessor<T> | ||
export interface SortOptions<TItem> extends TestOptions { | ||
iterator: ItemIterator<TItem>; | ||
} | ||
/** | ||
* @ignore | ||
*/ | ||
const get = <T>(source: T, accessor?: Accessor<T>): string => { | ||
if (typeof accessor === 'function') return accessor(source) | ||
if (typeof source === 'string') return source | ||
type FilterIterator<TItem> = (item: TItem) => boolean; | ||
type SortIterator<TItem> = (leftItem: TItem, rightItem: TItem) => number; | ||
throw new TypeError(`Unexpected array of ${typeof source}. Use an accessor to return a string`) | ||
} | ||
/** | ||
* This array helper can be used as an `Array.prototype.filter` callback as it | ||
* will return true or false when passing it a source string. The only | ||
* difference with `test` is that you can use it with an object, as long as you | ||
* give a `sourceAccessor` in options. | ||
* | ||
* @param query The input query | ||
* @param options The options as defined in [[FilterOptions]] | ||
* @returns A function that you can pass into `Array.prototype.filter` | ||
* will return true or false when passing it a source string. | ||
*/ | ||
export const filter = <T>(query: string, options: FilterOptions<T> = {}) => (source: T) => | ||
test(query, get(source, options.sourceAccessor), options) | ||
export function filter<TItem>( | ||
query: string, | ||
options: FilterOptions<TItem> | ||
): FilterIterator<TItem> { | ||
return function (item) { | ||
const source = options.iterator(item); | ||
return test(query, source, options); | ||
}; | ||
} | ||
/** | ||
* This array helper can be used as an `Array.prototype.sort` callback as it | ||
* will return `-1`/`0`/`1` when passing it two source strings. It is based on | ||
* match with default options set (`withRanges` to false and `withScore` to | ||
* true). You can also use `sourceAccessor` if you intend to sort over an array | ||
* of objects and `idAccessor` to optimize the performances. | ||
* | ||
* @param query The input query | ||
* @param options The options as defined in [[SortOptions]] | ||
* @returns A function that you can pass into `Array.prototype.sort` | ||
* will return `-1`/`0`/`1` when passing it two source strings. | ||
*/ | ||
export const sort = <T>(query: string, options: SortOptions<T> = {}) => { | ||
const matchOptions: MatchOptions = { | ||
...options, | ||
withRanges: false, | ||
withScore: true | ||
} | ||
export function sort<TItem>( | ||
query: string, | ||
options: SortOptions<TItem> | ||
): SortIterator<TItem> { | ||
const cacheMap: Map<string, number> = new Map(); | ||
if (!options.idAccessor) { | ||
return (leftSource: T, rightSource: T) => { | ||
const leftScore = match(query, get(leftSource, options.sourceAccessor), matchOptions).score! | ||
const rightScore = match(query, get(rightSource, options.sourceAccessor), matchOptions).score! | ||
return (leftItem, rightItem) => { | ||
const leftSource = options.iterator(leftItem); | ||
const rightSource = options.iterator(rightItem); | ||
if (rightScore === leftScore) return 0 | ||
return rightScore > leftScore ? 1 : -1 | ||
} | ||
} | ||
const cachedLeftMatch = cacheMap.get(leftSource); | ||
const cachedRightMatch = cacheMap.get(rightSource); | ||
const memo: { [key: string]: number } = {} | ||
const leftScore = cachedLeftMatch | ||
? cachedLeftMatch | ||
: match(query, leftSource, { | ||
withScore: true, | ||
caseSensitive: options.caseSensitive, | ||
}).score; | ||
return (leftSource: any, rightSource: any) => { | ||
const leftId = get(leftSource, options.idAccessor) | ||
const rightId = get(rightSource, options.idAccessor) | ||
const rightScore = cachedRightMatch | ||
? cachedRightMatch | ||
: match(query, rightSource, { | ||
withScore: true, | ||
caseSensitive: options.caseSensitive, | ||
}).score; | ||
const leftScore: number = memo.hasOwnProperty(leftId) | ||
? memo[leftId] | ||
: match(query, get(leftSource, options.sourceAccessor), matchOptions).score! | ||
if (!cacheMap.has(leftSource)) { | ||
cacheMap.set(leftSource, leftScore); | ||
} | ||
const rightScore: number = memo.hasOwnProperty(rightId) | ||
? memo[rightId] | ||
: match(query, get(rightSource, options.sourceAccessor), matchOptions).score! | ||
if (!memo.hasOwnProperty(leftId)) { | ||
memo[leftId] = leftScore | ||
if (!cacheMap.has(rightSource)) { | ||
cacheMap.set(rightSource, rightScore); | ||
} | ||
if (!memo.hasOwnProperty(rightId)) { | ||
memo[rightId] = rightScore | ||
if (rightScore === leftScore) { | ||
return 0; | ||
} | ||
if (rightScore === leftScore) return 0 | ||
return rightScore > leftScore ? 1 : -1 | ||
} | ||
return rightScore > leftScore ? 1 : -1; | ||
}; | ||
} |
@@ -1,4 +0,4 @@ | ||
export { test, TestOptions } from './test' | ||
export { match, MatchOptions, MatchResult, MatchRange, ScoreContext, ScoreStrategy } from './match' | ||
export { filter, sort, FilterOptions, SortOptions, Accessor } from './array' | ||
export { surround, SurroundOptions } from './surround' | ||
export { test, TestOptions } from "./test"; | ||
export { match, MatchRange, ScoreContext, ScoreStrategy } from "./match"; | ||
export { filter, sort, FilterOptions, SortOptions } from "./array"; | ||
export { surround, SurroundOptions } from "./surround"; |
173
src/match.ts
@@ -1,31 +0,7 @@ | ||
import { TestOptions } from './test' | ||
import { prepare } from './utils/prepare' | ||
import { pushRange } from './utils/range' | ||
import { pushScore } from './score/defaultStrategy' | ||
import { isLeading } from './utils/isLeading' | ||
import { reshapeInput } from "./utils/prepare"; | ||
import { pushRange } from "./utils/range"; | ||
import { pushScore } from "./score/defaultStrategy"; | ||
import { isLeading } from "./utils/isLeading"; | ||
/** | ||
* This represents match options. It is based on [[TestOptions]] but allows | ||
* scores and ranges to be returned as well. | ||
*/ | ||
export interface MatchOptions extends TestOptions { | ||
strategy?: ScoreStrategy | ||
withRanges?: boolean | ||
withScore?: boolean | ||
} | ||
/** | ||
* This represents match result as returned by the match function. It will | ||
* always contains a `match` boolean (which would be equivalent to `test` | ||
* function). If you set `withRanges` to true, match will return a `ranges` | ||
* array, and if you set `withScore` to true, match will return a `score` | ||
* number. | ||
*/ | ||
export interface MatchResult { | ||
match: boolean | ||
score?: number | ||
ranges?: MatchRange[] | ||
} | ||
/** | ||
* This represents a Range that you can get if you call match with `withRanges` | ||
@@ -36,4 +12,4 @@ * set to true. It is composed of indexes of the source string that are matched | ||
export interface MatchRange { | ||
start: number | ||
stop: number | ||
start: number; | ||
stop: number; | ||
} | ||
@@ -45,3 +21,3 @@ | ||
* - `currentScore` the actual score (ie. the result of the last `pushScore` call or 0) | ||
* - `character` the actual source character. It must not be reshaped (ie. lowercased or normalized) | ||
* - `character` the actual source character. It must not be reshaped (ie. lower-cased or normalized) | ||
* - `match` wether or not the actual source character is matched by the query | ||
@@ -51,6 +27,6 @@ * - `leading` wether or not the actual source character is a leading character (as returned by the `isLeading` function) | ||
export interface ScoreContext { | ||
currentScore: number | ||
character: string | ||
match: boolean | ||
leading: boolean | ||
currentScore: number; | ||
character: string; | ||
match: boolean; | ||
leading: boolean; | ||
} | ||
@@ -66,31 +42,49 @@ | ||
*/ | ||
export type ScoreStrategy = (previousContext: ScoreContext | null, context: ScoreContext) => number | ||
export type ScoreStrategy = ( | ||
previousContext: ScoreContext | null, | ||
context: ScoreContext | ||
) => number; | ||
/** | ||
* Returns wether or not the query fuzzy matches the source. Called without | ||
* options would be strictly equivalent as `{ match: test(query, source) }` but | ||
* less optimized. You should use `withScore` or `withRanges` options to get | ||
* additional informations about the match. | ||
* | ||
* @param query The input query | ||
* @param source The input source | ||
* @param opts Options as defined by [[MatchOptions]] | ||
* @returns An object as defined by [[MatchResult]] | ||
* Returns wether or not the query fuzzy matches the source | ||
*/ | ||
export const match = ( | ||
export function match( | ||
query: string, | ||
source: string | ||
): { match: boolean; ranges: MatchRange[]; score: number }; | ||
export function match( | ||
query: string, | ||
source: string, | ||
opts: MatchOptions = { withScore: true, strategy: pushScore } | ||
): MatchResult => { | ||
const [reshapedQuery, reshapedSource] = prepare(query, source, opts) | ||
opts: { caseSensitive?: boolean } | ||
): { match: boolean; ranges: MatchRange[]; score: number }; | ||
export function match( | ||
query: string, | ||
source: string, | ||
opts: { withScore?: true; caseSensitive?: boolean } | ||
): { match: boolean; ranges: MatchRange[]; score: number }; | ||
export function match( | ||
query: string, | ||
source: string, | ||
opts: { withScore?: false; caseSensitive?: boolean } | ||
): { match: boolean; ranges: MatchRange[] }; | ||
export function match( | ||
query: string, | ||
source: string, | ||
opts: { | ||
withScore?: boolean; | ||
caseSensitive?: boolean; | ||
} = { withScore: true } | ||
): { match: boolean; score?: number; ranges: MatchRange[] } { | ||
const [reshapedQuery, reshapedSource] = reshapeInput(query, source, opts); | ||
const withScore = !(opts?.withScore === false); | ||
// if no source, then only return true if query is also empty | ||
if (!reshapedSource.length || !reshapedQuery.length) { | ||
const result: MatchResult = { match: !query.length } | ||
if (opts.withRanges) | ||
result.ranges = !query.length ? [{ start: 0, stop: reshapedSource.length }] : [] | ||
if (opts.withScore) result.score = Number(!query.length) | ||
return result | ||
if (reshapedSource.length === 0 || reshapedQuery.length === 0) { | ||
return { | ||
match: query.length === 0, | ||
ranges: | ||
query.length === 0 ? [{ start: 0, stop: reshapedSource.length }] : [], | ||
score: withScore ? (query.length === 0 ? 1 : 0) : undefined, | ||
}; | ||
} | ||
@@ -100,25 +94,20 @@ | ||
if (reshapedQuery.length > reshapedSource.length) { | ||
const result: MatchResult = { match: false, score: 0 } | ||
if (opts.withRanges) result.ranges = [] | ||
if (opts.withScore) result.score = 0 | ||
return result | ||
return { match: false, ranges: [], score: withScore ? 0 : undefined }; | ||
} | ||
let queryPos = 0 | ||
let sourcePos = 0 | ||
let score = 0 | ||
let lastContext: ScoreContext | null = null | ||
let ranges: MatchRange[] = [] | ||
let queryPos = 0; | ||
let sourcePos = 0; | ||
let score = 0; | ||
let lastContext: ScoreContext | undefined; | ||
let ranges: MatchRange[] = []; | ||
// loop on source string | ||
while (sourcePos < source.length) { | ||
const actualSourceCharacter = reshapedSource[sourcePos] | ||
const queryCharacterWaitingForMatch = reshapedQuery[queryPos] | ||
const match = actualSourceCharacter === queryCharacterWaitingForMatch | ||
const actualSourceCharacter = reshapedSource[sourcePos]; | ||
const queryCharacterWaitingForMatch = reshapedQuery[queryPos]; | ||
const match = actualSourceCharacter === queryCharacterWaitingForMatch; | ||
if (opts.withScore) { | ||
if (withScore) { | ||
// context does not use reshaped as uppercase changes score | ||
const previousCharacter = sourcePos > 0 ? source[sourcePos - 1] : '' | ||
const previousCharacter = sourcePos > 0 ? source[sourcePos - 1] : ""; | ||
@@ -129,8 +118,8 @@ const newContext: ScoreContext = { | ||
match, | ||
leading: isLeading(previousCharacter, source[sourcePos]) | ||
} | ||
leading: isLeading(previousCharacter, source[sourcePos]), | ||
}; | ||
score = pushScore(lastContext, newContext) | ||
score = pushScore(lastContext, newContext); | ||
lastContext = newContext | ||
lastContext = newContext; | ||
} | ||
@@ -141,28 +130,24 @@ | ||
// push range to result | ||
if (opts.withRanges) { | ||
ranges = pushRange(ranges, sourcePos) | ||
} | ||
ranges = pushRange(ranges, sourcePos); | ||
// move query pos | ||
queryPos += 1 | ||
queryPos += 1; | ||
} | ||
sourcePos += 1 | ||
sourcePos += 1; | ||
} | ||
if (queryPos === reshapedQuery.length) { | ||
const result: MatchResult = { match: true } | ||
if (opts.withRanges) result.ranges = ranges | ||
if (opts.withScore) result.score = score | ||
return result | ||
return { | ||
match: true, | ||
ranges, | ||
score: withScore ? score : undefined, | ||
}; | ||
} | ||
const result: MatchResult = { match: false } | ||
if (opts.withRanges) result.ranges = [] | ||
if (opts.withScore) result.score = 0 | ||
return result | ||
return { | ||
match: false, | ||
ranges: [], | ||
score: withScore ? 0 : undefined, | ||
}; | ||
} |
@@ -1,2 +0,2 @@ | ||
import { ScoreContext } from '../match' | ||
import { ScoreContext } from "../match"; | ||
@@ -11,26 +11,29 @@ /** | ||
* | ||
* @param previousContext The last context given to pushScore | ||
* @param previousContext The last context given to pushScore. undefined if first match | ||
* @param context The actual context | ||
* @returns The new score | ||
*/ | ||
export const pushScore = (previousContext: ScoreContext | null, context: ScoreContext) => { | ||
export function pushScore( | ||
previousContext: ScoreContext | undefined, | ||
context: ScoreContext | ||
): number { | ||
if (!context) { | ||
throw new TypeError('Expecting context to be defined') | ||
throw new TypeError("Expecting context to be defined"); | ||
} | ||
if (!context.match) { | ||
return context.currentScore - 1 | ||
return context.currentScore - 1; | ||
} | ||
let increment = 0 | ||
let increment = 0; | ||
if (previousContext && previousContext.match) { | ||
increment += 5 | ||
increment += 5; | ||
} | ||
if (context.leading) { | ||
increment += 10 | ||
increment += 10; | ||
} | ||
return context.currentScore + increment | ||
return context.currentScore + increment; | ||
} |
@@ -1,58 +0,46 @@ | ||
import { MatchRange } from './match' | ||
import { MatchRange } from "./match"; | ||
/** | ||
* This represents the options you can pass to surround. | ||
* It requires at least `result` (that you'll get in `match` with the | ||
* `withRanges` option set to true). You can give a prefix and a suffix, which | ||
* will surround every part of the source string that matched the query. | ||
*/ | ||
export interface SurroundOptions { | ||
result: { | ||
ranges: MatchRange[] | ||
} | ||
prefix?: string | ||
suffix?: string | ||
ranges: MatchRange[]; | ||
}; | ||
prefix?: string; | ||
suffix?: string; | ||
} | ||
/** | ||
* @ignore | ||
*/ | ||
const insertAt = (input: string, index: number, patch: string = '') => | ||
input.slice(0, index) + patch + input.slice(index) | ||
/** | ||
* Surround parts of the string that matched with prefix and suffix. | ||
* Useful to emphasize the parts that matched. | ||
* | ||
* @param source The input source | ||
* @param options Options as defined by [[SurroundOptions]] | ||
* @returns The input source with matching ranges surrounded by prefix and suffix | ||
*/ | ||
export const surround = (source: string, options: SurroundOptions) => { | ||
if (typeof source !== 'string') { | ||
throw new TypeError('Expecting source to be a string') | ||
export function surround(source: string, options: SurroundOptions): string { | ||
if (typeof source !== "string") { | ||
throw new TypeError("Expecting source to be a string"); | ||
} | ||
if (!source.length) { | ||
return '' | ||
if (source.length === 0) { | ||
return ""; | ||
} | ||
if (!options || !options.result || !options.result.ranges || !options.result.ranges.length) { | ||
return source | ||
if (!options?.result?.ranges?.length) { | ||
return source; | ||
} | ||
let result = source | ||
let accumulator = 0 | ||
let result = source; | ||
let accumulator = 0; | ||
for (let range of options.result.ranges) { | ||
result = insertAt(result, range.start + accumulator, options.prefix) | ||
for (const range of options.result.ranges) { | ||
result = insertAt(result, range.start + accumulator, options.prefix); | ||
accumulator += (options.prefix || '').length | ||
accumulator += (options.prefix ?? "").length; | ||
result = insertAt(result, range.stop + accumulator, options.suffix) | ||
result = insertAt(result, range.stop + accumulator, options.suffix); | ||
accumulator += (options.suffix || '').length | ||
accumulator += (options.suffix ?? "").length; | ||
} | ||
return result | ||
return result; | ||
} | ||
function insertAt(input: string, index: number, patch = ""): string { | ||
return input.slice(0, index) + patch + input.slice(index); | ||
} |
@@ -1,2 +0,2 @@ | ||
import { prepare } from './utils/prepare' | ||
import { reshapeInput } from "./utils/prepare"; | ||
@@ -8,3 +8,3 @@ /** | ||
export interface TestOptions { | ||
caseSensitive?: boolean | ||
caseSensitive?: boolean; | ||
} | ||
@@ -21,12 +21,16 @@ | ||
*/ | ||
export const test = (query: string, source: string, opts: TestOptions = {}) => { | ||
const [reshapedQuery, reshapedSource] = prepare(query, source, opts) | ||
export function test( | ||
query: string, | ||
source: string, | ||
opts: TestOptions = {} | ||
): boolean { | ||
const [reshapedQuery, reshapedSource] = reshapeInput(query, source, opts); | ||
// if no source, then only return true if query is also empty | ||
if (!reshapedSource.length) { | ||
return !query.length | ||
return !query.length; | ||
} | ||
if (!reshapedQuery.length) { | ||
return true | ||
return true; | ||
} | ||
@@ -36,12 +40,12 @@ | ||
if (reshapedQuery.length > reshapedSource.length) { | ||
return false | ||
return false; | ||
} | ||
let queryPos = 0 | ||
let sourcePos = 0 | ||
let queryPos = 0; | ||
let sourcePos = 0; | ||
// loop on source string | ||
while (sourcePos < source.length) { | ||
const actualSourceCharacter = reshapedSource[sourcePos] | ||
const queryCharacterWaitingForMatch = reshapedQuery[queryPos] | ||
const actualSourceCharacter = reshapedSource[sourcePos]; | ||
const queryCharacterWaitingForMatch = reshapedQuery[queryPos]; | ||
@@ -51,9 +55,9 @@ // if actual query character matches source character | ||
// move query pos | ||
queryPos += 1 | ||
queryPos += 1; | ||
} | ||
sourcePos += 1 | ||
sourcePos += 1; | ||
} | ||
return queryPos === reshapedQuery.length | ||
return queryPos === reshapedQuery.length; | ||
} |
@@ -1,2 +0,2 @@ | ||
import { toLatin } from './toLatin' | ||
import { toLatin } from "./toLatin"; | ||
@@ -13,14 +13,14 @@ /** | ||
*/ | ||
export const isLeading = (prevChar: string, char: string) => { | ||
export function isLeading(prevChar: string, char: string): boolean { | ||
const precededBySeparator = | ||
prevChar === '-' || | ||
prevChar === '_' || | ||
prevChar === ' ' || | ||
prevChar === '.' || | ||
prevChar === '/' || | ||
prevChar === '\\' | ||
prevChar === "-" || | ||
prevChar === "_" || | ||
prevChar === " " || | ||
prevChar === "." || | ||
prevChar === "/" || | ||
prevChar === "\\"; | ||
const isCharLeading = char.toUpperCase() === char && /\w/.test(toLatin(char)) | ||
const isCharLeading = char.toUpperCase() === char && /\w/.test(toLatin(char)); | ||
return precededBySeparator || isCharLeading | ||
return precededBySeparator || isCharLeading; | ||
} |
@@ -1,2 +0,2 @@ | ||
import { TestOptions } from '../test' | ||
import { TestOptions } from "../test"; | ||
@@ -12,20 +12,24 @@ /** | ||
*/ | ||
export const prepare = (query: string, source: string, opts: TestOptions) => { | ||
if (typeof query !== 'string') { | ||
throw new TypeError('Expecting query to be a string') | ||
export function reshapeInput( | ||
query: string, | ||
source: string, | ||
opts: TestOptions | ||
): [string, string] { | ||
if (typeof query !== "string") { | ||
throw new TypeError("Expecting query to be a string"); | ||
} | ||
if (typeof source !== 'string') { | ||
throw new TypeError('Expecting source to be a string') | ||
if (typeof source !== "string") { | ||
throw new TypeError("Expecting source to be a string"); | ||
} | ||
let reshapedQuery = query.normalize('NFD').replace(/[\u0300-\u036f]/g, '') | ||
let reshapedSource = source.normalize('NFD').replace(/[\u0300-\u036f]/g, '') | ||
let reshapedQuery = query.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); | ||
let reshapedSource = source.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); | ||
if (!opts.caseSensitive) { | ||
reshapedQuery = reshapedQuery.toLowerCase() | ||
reshapedSource = reshapedSource.toLowerCase() | ||
reshapedQuery = reshapedQuery.toLowerCase(); | ||
reshapedSource = reshapedSource.toLowerCase(); | ||
} | ||
return [reshapedQuery, reshapedSource] | ||
return [reshapedQuery, reshapedSource]; | ||
} |
@@ -1,2 +0,2 @@ | ||
import { MatchRange } from '../match' | ||
import { MatchRange } from "../match"; | ||
@@ -12,4 +12,7 @@ /** | ||
*/ | ||
export const pushRange = (ranges: MatchRange[], sourcePos: number): MatchRange[] => { | ||
const lastRange = ranges[ranges.length - 1] | ||
export function pushRange( | ||
ranges: MatchRange[], | ||
sourcePos: number | ||
): MatchRange[] { | ||
const lastRange = ranges[ranges.length - 1]; | ||
@@ -21,8 +24,8 @@ if (lastRange && lastRange.stop === sourcePos) { | ||
start: lastRange.start, | ||
stop: sourcePos + 1 | ||
} | ||
] | ||
stop: sourcePos + 1, | ||
}, | ||
]; | ||
} else { | ||
return [...ranges, { start: sourcePos, stop: sourcePos + 1 }] | ||
return [...ranges, { start: sourcePos, stop: sourcePos + 1 }]; | ||
} | ||
} |
@@ -9,2 +9,4 @@ /** | ||
*/ | ||
export const toLatin = (str: string) => str.normalize('NFD').replace(/[\u0300-\u036f]/g, '') | ||
export function toLatin(str: string): string { | ||
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); | ||
} |
{ | ||
"include": ["./src/**/*.ts"], | ||
"exclude": ["./src/**/__tests__"], | ||
"compilerOptions": { | ||
"moduleResolution": "node", | ||
"target": "es5", | ||
"module":"es2015", | ||
"lib": ["es2015", "es2016", "es2017", "dom"], | ||
"lib": ["es2020"], | ||
"module": "commonjs", | ||
"target": "es2020", | ||
"rootDir": "./src", | ||
"outDir": "build", | ||
"strict": true, | ||
"sourceMap": true, | ||
"declaration": true, | ||
"allowSyntheticDefaultImports": true, | ||
"experimentalDecorators": true, | ||
"emitDecoratorMetadata": true, | ||
"declarationDir": "dist/types", | ||
"outDir": "dist/lib", | ||
"esModuleInterop": true, | ||
"typeRoots": [ | ||
"node_modules/@types" | ||
] | ||
}, | ||
"include": [ | ||
"src" | ||
] | ||
"skipLibCheck": true, | ||
"forceConsistentCasingInFileNames": true, | ||
"declaration": true | ||
} | ||
} |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
No repository
Supply chain riskPackage does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
Found 1 instance in 1 package
No website
QualityPackage does not have a website.
Found 1 instance in 1 package
24
0
203
461772
56
25775
1