diff --git a/src/__tests__/index.ts b/src/__tests__/index.ts index bcb380e..ea3656f 100644 --- a/src/__tests__/index.ts +++ b/src/__tests__/index.ts @@ -83,24 +83,25 @@ const tests: Record = { {name: 'baz', reverse: 'zab'}, ], }, - 'with multiple keys specified, all other things being equal, it prioritizes key index over alphabetizing': { - input: [ - [ + 'with multiple keys specified, all other things being equal, it prioritizes key index over alphabetizing': + { + input: [ + [ + {first: 'not', second: 'not', third: 'match'}, + {first: 'not', second: 'not', third: 'not', fourth: 'match'}, + {first: 'not', second: 'match'}, + {first: 'match', second: 'not'}, + ], + 'match', + {keys: ['first', 'second', 'third', 'fourth']}, + ], + output: [ + {first: 'match', second: 'not'}, + {first: 'not', second: 'match'}, {first: 'not', second: 'not', third: 'match'}, {first: 'not', second: 'not', third: 'not', fourth: 'match'}, - {first: 'not', second: 'match'}, - {first: 'match', second: 'not'}, ], - 'match', - {keys: ['first', 'second', 'third', 'fourth']}, - ], - output: [ - {first: 'match', second: 'not'}, - {first: 'not', second: 'match'}, - {first: 'not', second: 'not', third: 'match'}, - {first: 'not', second: 'not', third: 'not', fourth: 'match'}, - ], - }, + }, 'can handle the number 0 as a property value': { input: [ [ @@ -129,36 +130,67 @@ const tests: Record = { ], output: [{name: {first: 'bat'}}, {name: {first: 'baz'}}], }, - 'can handle object with an array of values with nested keys with a specific index': { - input: [ - [ - {aliases: [{name: {first: 'baz'}},{name: {first: 'foo'}},{name: null}]}, - {aliases: [{name: {first: 'foo'}},{name: {first: 'bat'}},null]}, - {aliases: [{name: {first: 'foo'}},{name: {first: 'foo'}}]}, - {aliases: null}, - {}, - null, + 'can handle object with an array of values with nested keys with a specific index': + { + input: [ + [ + { + aliases: [ + {name: {first: 'baz'}}, + {name: {first: 'foo'}}, + {name: null}, + ], + }, + {aliases: [{name: {first: 'foo'}}, {name: {first: 'bat'}}, null]}, + {aliases: [{name: {first: 'foo'}}, {name: {first: 'foo'}}]}, + {aliases: null}, + {}, + null, + ], + 'ba', + {keys: ['aliases.0.name.first']}, ], - 'ba', - {keys: ['aliases.0.name.first']}, - ], - output: [{aliases: [{name: {first: 'baz'}},{name: {first: 'foo'}},{name: null}]}], - }, - 'can handle object with an array of values with nested keys with a wildcard': { - input: [ - [ - {aliases: [{name: {first: 'baz'}},{name: {first: 'foo'}},{name: null}]}, - {aliases: [{name: {first: 'foo'}},{name: {first: 'bat'}},null]}, - {aliases: [{name: {first: 'foo'}},{name: {first: 'foo'}}]}, - {aliases: null}, - {}, - null, + output: [ + { + aliases: [ + {name: {first: 'baz'}}, + {name: {first: 'foo'}}, + {name: null}, + ], + }, ], - 'ba', - {keys: ['aliases.*.name.first']}, - ], - output: [{aliases: [{name: {first: 'baz'}},{name: {first: 'foo'}},{name: null}]}, {aliases: [{name: {first: 'foo'}},{name: {first: 'bat'}},null]}], - }, + }, + 'can handle object with an array of values with nested keys with a wildcard': + { + input: [ + [ + { + aliases: [ + {name: {first: 'baz'}}, + {name: {first: 'foo'}}, + {name: null}, + ], + }, + {aliases: [{name: {first: 'foo'}}, {name: {first: 'bat'}}, null]}, + {aliases: [{name: {first: 'foo'}}, {name: {first: 'foo'}}]}, + {aliases: null}, + {}, + null, + ], + 'ba', + {keys: ['aliases.*.name.first']}, + ], + output: [ + { + aliases: [ + {name: {first: 'baz'}}, + {name: {first: 'foo'}}, + {name: null}, + ], + }, + {aliases: [{name: {first: 'foo'}}, {name: {first: 'bat'}}, null]}, + ], + }, 'can handle property callback': { input: [ [{name: {first: 'baz'}}, {name: {first: 'bat'}}, {name: {first: 'foo'}}], @@ -228,34 +260,107 @@ const tests: Record = { {favorite: {iceCream: ['mint', 'chocolate']}}, ], }, - 'can handle nested keys that are an array of objects with a single wildcard': { - input: [ - [ - {favorite: {iceCream: [{tastes: ['vanilla', 'mint']}, {tastes: ['vanilla', 'chocolate']}]}}, - {favorite: {iceCream: [{tastes: ['vanilla', 'candy cane']}, {tastes: ['vanilla', 'brownie']}]}}, - {favorite: {iceCream: [{tastes: ['vanilla', 'birthday cake']}, {tastes: ['vanilla', 'rocky road']}, {tastes: ['strawberry']}]}}, + 'can handle nested keys that are an array of objects with a single wildcard': + { + input: [ + [ + { + favorite: { + iceCream: [ + {tastes: ['vanilla', 'mint']}, + {tastes: ['vanilla', 'chocolate']}, + ], + }, + }, + { + favorite: { + iceCream: [ + {tastes: ['vanilla', 'candy cane']}, + {tastes: ['vanilla', 'brownie']}, + ], + }, + }, + { + favorite: { + iceCream: [ + {tastes: ['vanilla', 'birthday cake']}, + {tastes: ['vanilla', 'rocky road']}, + {tastes: ['strawberry']}, + ], + }, + }, + ], + 'cc', + {keys: ['favorite.iceCream.*.tastes']}, ], - 'cc', - {keys: ['favorite.iceCream.*.tastes']}, - ], - output: [ - {favorite: {iceCream: [{tastes:['vanilla', 'candy cane']}, {tastes:['vanilla', 'brownie']}]}}, - {favorite: {iceCream: [{tastes:['vanilla', 'mint']}, {tastes:['vanilla', 'chocolate']}]}}, - ], - }, + output: [ + { + favorite: { + iceCream: [ + {tastes: ['vanilla', 'candy cane']}, + {tastes: ['vanilla', 'brownie']}, + ], + }, + }, + { + favorite: { + iceCream: [ + {tastes: ['vanilla', 'mint']}, + {tastes: ['vanilla', 'chocolate']}, + ], + }, + }, + ], + }, 'can handle nested keys that are an array of objects with two wildcards': { input: [ [ - {favorite: {iceCream: [{tastes: ['vanilla', 'mint']}, {tastes: ['vanilla', 'chocolate']}]}}, - {favorite: {iceCream: [{tastes: ['vanilla', 'candy cane']}, {tastes: ['vanilla', 'brownie']}]}}, - {favorite: {iceCream: [{tastes: ['vanilla', 'birthday cake']}, {tastes: ['vanilla', 'rocky road']}, {tastes: ['strawberry']}]}}, + { + favorite: { + iceCream: [ + {tastes: ['vanilla', 'mint']}, + {tastes: ['vanilla', 'chocolate']}, + ], + }, + }, + { + favorite: { + iceCream: [ + {tastes: ['vanilla', 'candy cane']}, + {tastes: ['vanilla', 'brownie']}, + ], + }, + }, + { + favorite: { + iceCream: [ + {tastes: ['vanilla', 'birthday cake']}, + {tastes: ['vanilla', 'rocky road']}, + {tastes: ['strawberry']}, + ], + }, + }, ], 'cc', {keys: ['favorite.iceCream.*.tastes.*']}, ], output: [ - {favorite: {iceCream: [{tastes:['vanilla', 'candy cane']}, {tastes:['vanilla', 'brownie']}]}}, - {favorite: {iceCream: [{tastes:['vanilla', 'mint']}, {tastes:['vanilla', 'chocolate']}]}}, + { + favorite: { + iceCream: [ + {tastes: ['vanilla', 'candy cane']}, + {tastes: ['vanilla', 'brownie']}, + ], + }, + }, + { + favorite: { + iceCream: [ + {tastes: ['vanilla', 'mint']}, + {tastes: ['vanilla', 'chocolate']}, + ], + }, + }, ], }, 'can handle keys with a maxRanking': { @@ -296,20 +401,21 @@ const tests: Record = { {tea: 'Oolong', alias: 'B'}, ], }, - 'when using arrays of values, when things are equal, the one with the higher key index wins': { - input: [ - [ - {favoriteIceCream: ['mint', 'chocolate']}, + 'when using arrays of values, when things are equal, the one with the higher key index wins': + { + input: [ + [ + {favoriteIceCream: ['mint', 'chocolate']}, + {favoriteIceCream: ['chocolate', 'brownie']}, + ], + 'chocolate', + {keys: ['favoriteIceCream']}, + ], + output: [ {favoriteIceCream: ['chocolate', 'brownie']}, + {favoriteIceCream: ['mint', 'chocolate']}, ], - 'chocolate', - {keys: ['favoriteIceCream']}, - ], - output: [ - {favoriteIceCream: ['chocolate', 'brownie']}, - {favoriteIceCream: ['mint', 'chocolate']}, - ], - }, + }, 'when providing a rank threshold of NO_MATCH, it returns all of the items': { input: [ ['orange', 'apple', 'grape', 'banana'], @@ -318,38 +424,59 @@ const tests: Record = { ], output: ['apple', 'grape', 'banana', 'orange'], }, - 'when providing a rank threshold of EQUAL, it returns only the items that are equal': { - input: [ - ['google', 'airbnb', 'apple', 'apply', 'app'], - 'app', - {threshold: rankings.EQUAL}, - ], - output: ['app'], - }, - 'when providing a rank threshold of CASE_SENSITIVE_EQUAL, it returns only case-sensitive equal matches': { - input: [ - ['google', 'airbnb', 'apple', 'apply', 'app', 'aPp', 'App'], - 'app', - {threshold: rankings.CASE_SENSITIVE_EQUAL}, - ], - output: ['app'], - }, - 'when providing a rank threshold of WORD_STARTS_WITH, it returns only the items that are equal': { - input: [ - ['fiji apple', 'google', 'app', 'crabapple', 'apple', 'apply'], - 'app', - {threshold: rankings.WORD_STARTS_WITH}, - ], - output: ['app', 'apple', 'apply', 'fiji apple'], - }, - 'when providing a rank threshold of ACRONYM, it returns only the items that meet the rank': { - input: [ - ['apple', 'atop', 'alpaca', 'vamped'], - 'ap', - {threshold: rankings.ACRONYM}, - ], - output: ['apple'], - }, + 'when providing a rank threshold of EQUAL, it returns only the items that are equal': + { + input: [ + ['google', 'airbnb', 'apple', 'apply', 'app'], + 'app', + {threshold: rankings.EQUAL}, + ], + output: ['app'], + }, + 'when providing a rank threshold of CASE_SENSITIVE_EQUAL, it returns only case-sensitive equal matches': + { + input: [ + ['google', 'airbnb', 'apple', 'apply', 'app', 'aPp', 'App'], + 'app', + {threshold: rankings.CASE_SENSITIVE_EQUAL}, + ], + output: ['app'], + }, + 'when providing a rank threshold of WORD_STARTS_WITH, it returns only the items that are equal': + { + input: [ + ['fiji apple', 'google', 'app', 'crabapple', 'apple', 'apply'], + 'app', + {threshold: rankings.WORD_STARTS_WITH}, + ], + output: ['app', 'apple', 'apply', 'fiji apple'], + }, + 'when providing a rank threshold of WORD_STARTS_WITH, correctly return items that have a word after a suffix': + { + input: [ + [ + 'fiji apple', + 'google', + 'app', + 'crabapple', + 'apple', + 'apply', + 'snappy apple', + ], + 'app', + {threshold: rankings.WORD_STARTS_WITH}, + ], + output: ['app', 'apple', 'apply', 'fiji apple', 'snappy apple'], + }, + 'when providing a rank threshold of ACRONYM, it returns only the items that meet the rank': + { + input: [ + ['apple', 'atop', 'alpaca', 'vamped'], + 'ap', + {threshold: rankings.ACRONYM}, + ], + output: ['apple'], + }, 'defaults to ignore diacritics': { input: [ ['jalapeño', 'à la carte', 'café', 'papier-mâché', 'à la mode'], @@ -427,32 +554,33 @@ const tests: Record = { input: [['Привет', 'Лед'], 'л'], output: ['Лед'], }, - 'should sort same ranked items alphabetically while when mixed with diacritics': { - input: [ - [ - 'jalapeño', - 'anothernodiacritics', + 'should sort same ranked items alphabetically while when mixed with diacritics': + { + input: [ + [ + 'jalapeño', + 'anothernodiacritics', + 'à la carte', + 'nodiacritics', + 'café', + 'papier-mâché', + 'à la mode', + ], + 'z', + { + threshold: rankings.NO_MATCH, + }, + ], + output: [ 'à la carte', - 'nodiacritics', + 'à la mode', + 'anothernodiacritics', 'café', + 'jalapeño', + 'nodiacritics', 'papier-mâché', - 'à la mode', ], - 'z', - { - threshold: rankings.NO_MATCH, - }, - ], - output: [ - 'à la carte', - 'à la mode', - 'anothernodiacritics', - 'café', - 'jalapeño', - 'nodiacritics', - 'papier-mâché', - ], - }, + }, 'returns objects in their original order': { input: [ [ @@ -495,18 +623,19 @@ const tests: Record = { ], output: [{name: 'Jen_Smith'}, {name: 'Janice_Kurtis'}], }, - 'support a custom sortRankedValues function to overriding all sorting functionality': { - input: [ - ['appl', 'C apple', 'B apple', 'A apple', 'app', 'applebutter'], - '', - { - sorter: rankedItems => { - return [...rankedItems].reverse() + 'support a custom sortRankedValues function to overriding all sorting functionality': + { + input: [ + ['appl', 'C apple', 'B apple', 'A apple', 'app', 'applebutter'], + '', + { + sorter: rankedItems => { + return [...rankedItems].reverse() + }, }, - }, - ], - output: ['applebutter', 'app', 'A apple', 'B apple', 'C apple', 'appl'], - }, + ], + output: ['applebutter', 'app', 'A apple', 'B apple', 'C apple', 'appl'], + }, } for (const [ diff --git a/src/index.ts b/src/index.ts index 317ac3f..1a9e3cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,7 +67,7 @@ const rankings = { NO_MATCH: 0, } as const -type Ranking = typeof rankings[keyof typeof rankings] +type Ranking = (typeof rankings)[keyof typeof rankings] const defaultBaseSortFn: BaseSorter = (a, b) => String(a.rankedValue).localeCompare(String(b.rankedValue)) @@ -126,7 +126,7 @@ function getHighestRanking( ): RankingInfo { if (!keys) { // if keys is not specified, then we assume the item given is ready to be matched - const stringItem = (item as unknown) as string + const stringItem = item as unknown as string return { // ends up being duplicate of 'item' in matches but consistent rankedValue: stringItem, @@ -159,7 +159,7 @@ function getHighestRanking( return {rankedValue: newRankedValue, rank, keyIndex, keyThreshold} }, { - rankedValue: (item as unknown) as string, + rankedValue: item as unknown as string, rank: rankings.NO_MATCH as Ranking, keyIndex: -1, keyThreshold: options.threshold, @@ -167,6 +167,14 @@ function getHighestRanking( ) } +function* indexesOf(testString: string, stringToRank: string) { + let index = -1 + while ((index = testString.indexOf(stringToRank, index + 1)) > -1) { + yield index + } + return -1 +} + /** * Gives a rankings score based on how well the two strings match. * @param {String} testString - the string to test against @@ -197,10 +205,17 @@ function getMatchRanking( stringToRank = stringToRank.toLowerCase() // Use indexOf to check for equality/includes - const indexOfStringToRankInTestString = testString.indexOf(stringToRank) + const indexesOfStringToRankInTestString = indexesOf(testString, stringToRank) + const firstIndexOfStringToRankInTestStringResult = + indexesOfStringToRankInTestString.next() + const indexOfStringToRankInTestString = + firstIndexOfStringToRankInTestStringResult.value // case insensitive equals - if (testString.length === stringToRank.length && indexOfStringToRankInTestString === 0) { + if ( + testString.length === stringToRank.length && + indexOfStringToRankInTestString === 0 + ) { return rankings.EQUAL } @@ -210,8 +225,18 @@ function getMatchRanking( } // word starts with - if (indexOfStringToRankInTestString > 0 && testString[indexOfStringToRankInTestString-1] === ' ') { - return rankings.WORD_STARTS_WITH + let indexOfStringToRankInTestStringResult = + firstIndexOfStringToRankInTestStringResult + while (!indexOfStringToRankInTestStringResult.done) { + if ( + indexOfStringToRankInTestStringResult.value > 0 && + testString[indexOfStringToRankInTestStringResult.value - 1] === ' ' + ) { + return rankings.WORD_STARTS_WITH + } + + indexOfStringToRankInTestStringResult = + indexesOfStringToRankInTestString.next() } // contains