Skip to content

Commit

Permalink
feat: support fuzzy search for list search (close #22) #23
Browse files Browse the repository at this point in the history
  • Loading branch information
Zhengqbbb authored Apr 30, 2022
2 parents 2c9d0d6 + f15ea52 commit 3cb13d7
Show file tree
Hide file tree
Showing 11 changed files with 215 additions and 165 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: ['14', '16']
node: ['14', '16', '18']

runs-on: ${{ matrix.os }}

Expand Down
112 changes: 112 additions & 0 deletions packages/@cz-git/plugin-inquirer/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { test, expect, describe } from "vitest";
import { fuzzyFilter, fuzzyMatch } from "../src";

/**
* @description: fuzzyMatch Test
*/
describe("fuzzyMatch", () => {
test("function should be check param fit", () => {
expect(fuzzyMatch(null, null)).toBe(null);
expect(fuzzyMatch(undefined, null)).toBe(null);
expect(fuzzyMatch(undefined, undefined)).toBe(null);
// @ts-ignore
expect(fuzzyMatch([], [])).toBe(null);
// @ts-ignore
expect(fuzzyMatch({}, {})).toBe(null);
});

test("match char should be return right score", () => {
expect(fuzzyMatch("a", "Apple")).toEqual(1);
expect(fuzzyMatch("ae", "Apple")).toEqual(2);
expect(fuzzyMatch("ap", "Apple")).toEqual(4);
expect(fuzzyMatch("app", "Apple")).toEqual(11);
expect(fuzzyMatch("ban", "banana")).toEqual(11);
expect(fuzzyMatch("bna", "banana")).toEqual(5);
expect(fuzzyMatch("baaa", "banana")).toEqual(6);
});

test("consistent case should be return same score", () => {
expect(fuzzyMatch("sz", "shenzhen")).toEqual(fuzzyMatch("sz", "ShenZhen"));
});

test("case sensitive should be return diff score", () => {
expect(fuzzyMatch("sz", "shenzhen", true)).toEqual(2);
expect(fuzzyMatch("sz", "ShenZhen", true)).toEqual(null);
});

test("not match char should be return null", () => {
expect(fuzzyMatch("k", "banana")).toEqual(null);
expect(fuzzyMatch("kkkkkk", "banana")).toEqual(null);
expect(fuzzyMatch("bne", "banana")).toEqual(null);
expect(fuzzyMatch("bnae", "banana")).toEqual(null);
});

test("all match should be return Infinity", () => {
expect(fuzzyMatch("apple", "Apple")).toEqual(Infinity);
expect(fuzzyMatch("Apple", "Apple")).toEqual(Infinity);
});
});

/**
* @description: fuzzyFilter Test
*/
describe("fuzzyFilter", () => {
const testArr = [
{ name: "cz-git", value: "cz-git" },
{ name: "plugin-inquirer", value: "plugin-inquirer" },
{ name: "plugin-loader", value: "plugin-loader" },
{ type: "separator", line: "\x1B[2m──────────────\x1B[22m" },
{ name: "custom", value: "___CUSTOM__" },
{ name: "empty", value: false }
];

test("function should be check param fit", () => {
expect(fuzzyFilter("", [])).toEqual([]);
expect(fuzzyFilter("", undefined)).toEqual([]);
expect(fuzzyFilter(undefined, undefined)).toEqual([]);
expect(fuzzyFilter("", null)).toEqual([]);
});

test("empty input should be return origin array", () => {
expect(fuzzyFilter("", testArr)).toBe(testArr);
});

test("normal match should be return right array", () => {
expect(fuzzyFilter("cz-git", testArr)).toEqual([
{ name: "cz-git", value: "cz-git", index: 0, score: Infinity }
]);
expect(fuzzyFilter("ty", testArr)).toEqual([
{ name: "empty", value: false, index: 5, score: 4 }
]);
expect(fuzzyFilter("inq", testArr)).toEqual([
{ name: "plugin-inquirer", value: "plugin-inquirer", index: 1, score: 5 }
]);
expect(fuzzyFilter("ii", testArr)).toEqual([
{ name: "plugin-inquirer", value: "plugin-inquirer", index: 1, score: 2 }
]);
});

test("same score shoule be return sort by index", () => {
expect(fuzzyFilter("plu", testArr)).toEqual([
{ name: "plugin-inquirer", value: "plugin-inquirer", index: 1, score: 11 },
{ name: "plugin-loader", value: "plugin-loader", index: 2, score: 11 }
]);
});

test("diff score shoule be return sort by score", () => {
const testArr = [
{ name: "anapple", value: "apple" },
{ name: "aapple", value: "apple" },
{ name: "apple", value: "apple" }
];
expect(fuzzyFilter("ap", testArr)).toEqual([
{ name: "apple", value: "apple", index: 2, score: 4 },
{ name: "anapple", value: "apple", index: 0, score: 2 },
{ name: "aapple", value: "apple", index: 1, score: 2 }
]);
expect(fuzzyFilter("aap", testArr)).toEqual([
{ name: "aapple", value: "apple", index: 1, score: 11 },
{ name: "anapple", value: "apple", index: 0, score: 5 }
]);
});
});
1 change: 0 additions & 1 deletion packages/@cz-git/plugin-inquirer/src/list/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,4 @@
* @license: MIT
*/

console.log("hello world");
export const hello = "hello world";
41 changes: 0 additions & 41 deletions packages/@cz-git/plugin-inquirer/src/shared/types/fuzzy.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/@cz-git/plugin-inquirer/src/shared/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from "./fuzzy";
export * from "./util";
export * from "./checkbox";
7 changes: 7 additions & 0 deletions packages/@cz-git/plugin-inquirer/src/shared/types/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface FilterArrayItemType {
value: string;
name: string;
emoji?: string;
index?: number;
score?: number;
}
155 changes: 58 additions & 97 deletions packages/@cz-git/plugin-inquirer/src/shared/utils/fuzzy.ts
Original file line number Diff line number Diff line change
@@ -1,127 +1,88 @@
/**
* Powered by Fuzzy
* https://github.com/myork/fuzzy
*
* Copyright (c) 2012 Matt York
* Licensed under the MIT license.
*
* @description: A standalone fuzzy search / fuzzy filter. provide inquirer usage
* @description: provide list and checkBox fuzzy search
* @author: @Zhengqbbb ([email protected])
* @license: MIT
*/

import { FilterFucType, MatchOptions, MatchResult } from "../types";
import type { FilterArrayItemType } from "../types";

/**
* @description: If `pattern` matches `inputString`, wrap each matching character in `opts.pre`
* and `opts.post`. If no match, return null.
* @param {string} pattern inputString
* @param {string} str targetString
* @description: inputString match targetString return match score
* @param {string} input input string
* @param {string} target target string
* @param {boolean} caseSensitive isCaseSensitive, default: false
* @return {number | null} match score. if not match return null
*/
export const fuzzyMatch = (
pattern: string,
str: string,
opts?: MatchOptions
): MatchResult | null => {
opts = opts || {};
const result = [];
const len = str.length;
// prefix
const pre = opts.pre || "";
// suffix
const post = opts.post || "";
// String to compare against. This might be a lowercase version of the
// raw string
const compareString = (opts.caseSensitive && str) || str.toLowerCase();
let patternIdx = 0,
input: string,
target: string,
caseSensitive?: boolean
): number | null => {
if (typeof input !== "string" || typeof target !== "string") return null;
const matchResult = [];
const len = target.length;
const shimTarget = (caseSensitive && target) || target.toLowerCase();
input = (caseSensitive && input) || input.toLowerCase();
let inputIndex = 0,
totalScore = 0,
currScore = 0,
ch;

pattern = (opts.caseSensitive && pattern) || pattern.toLowerCase();

// For each character in the string, either add it to the result
// or wrap in template if it's the next string in the pattern
currentScore = 0,
currentChar;
for (let idx = 0; idx < len; idx++) {
ch = str[idx];
if (compareString[idx] === pattern[patternIdx]) {
ch = pre + ch + post;
patternIdx += 1;

// consecutive characters should increase the score more than linearly
currScore += 1 + currScore;
currentChar = input[idx];
if (shimTarget[idx] === input[inputIndex]) {
// consecutive matches will score higher
inputIndex += 1;
currentScore += 1 + currentScore;
} else {
currScore = 0;
currentScore = 0;
}
totalScore += currScore;
result[result.length] = ch;
totalScore += currentScore;
matchResult[matchResult.length] = currentChar;
}

// return rendered string if we have a match for every char
if (patternIdx === pattern.length) {
// if the string is an exact match with pattern, totalScore should be maxed
totalScore = compareString === pattern ? Infinity : totalScore;
return { rendered: result.join(""), score: totalScore };
if (inputIndex === input.length) {
totalScore = shimTarget === input ? Infinity : totalScore;
return totalScore;
}

return null;
};

/**
* @description: Does `pattern` fuzzy match `inputString`?
* @param {string} pattern inputString
* @param {string} str targetString
* @return {boolean} isMatch
*/
export const fuzzyTest = (pattern: string, str: string): boolean => {
return fuzzyMatch(pattern, str) !== null;
};

/**
* @description: The normal entry point. Filters `arr` for matches against `pattern`.
* @param {*} pattern inputString
* @param {*} arr targetArray
* @param {*} opts FilterOptions
* @description: Array fuzzy filter
* @param {string} input input string
* @param {Array<FilterArrayItemType | unknown>} arr target Array
* @return {Array<FilterArrayItemType>} filtered array
*/
export const fuzzyFilter: FilterFucType<any> = (pattern, arr, opts?) => {
if (!arr || arr.length === 0) {
export const fuzzyFilter = (
input: string,
arr: Array<FilterArrayItemType | unknown>,
targetKey: "name" | "value" = "name"
): Array<FilterArrayItemType> => {
if (!arr || !Array.isArray(arr) || arr.length === 0) {
return [];
}
if (typeof pattern !== "string") {
} else if (typeof input !== "string" || input === "") {
return arr;
}
opts = opts || {};

return arr
.reduce(function (prev, element, idx) {
let str = element;
if (opts?.extract) {
str = opts?.extract(element);
.reduce((preVal: Array<FilterArrayItemType>, curItem: FilterArrayItemType, index) => {
if (!curItem || !curItem[targetKey]) return preVal;
const score = fuzzyMatch(input, curItem[targetKey]);
if (score !== null) {
preVal.push({
score,
index,
...curItem
});
}
const rendered = fuzzyMatch(pattern, str, opts);
if (rendered != null) {
prev[prev.length] = {
string: rendered.rendered,
score: rendered.score,
index: idx,
original: element
};
}
return prev;
return preVal;
}, [])
.sort(function (a: any, b: any) {
.sort((a: any, b: any) => {
const compare = b.score - a.score;
if (compare) return compare;
return a.index - b.index;
if (compare) {
return compare;
} else {
return a.index - b.index;
}
});
};

/**
* @description: Return all elements of `array` that have a fuzzy match against `pattern`.
* @param {string} pattern inputString
* @param {Array<string>} array targetArray
*/
export const fuzzySimpleFilter = (pattern: string, array: string[]): string[] => {
return array.filter(function (str) {
return fuzzyTest(pattern, str);
});
};
16 changes: 12 additions & 4 deletions packages/cz-git/__tests__/generateQuestions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ describe("generateQuestions()", () => {
expect(mockTypesSourceFn({}, "f")).toEqual([
{
value: "feat",
name: "feat: this is a feature"
name: "feat: this is a feature",
index: 0,
score: 1
}
]);

Expand All @@ -57,9 +59,15 @@ describe("generateQuestions()", () => {
{},
{ name: "cz-git", value: "cz-git" }
]);
expect(mockTypesSourceFn({}, "cz")).toEqual([{ name: "cz-git", value: "cz-git" }]);
expect(mockTypesSourceFn({}, "em")).toEqual([{ name: "empty", value: false }]);
expect(mockTypesSourceFn({}, "cu")).toEqual([{ name: "custom", value: "___CUSTOM___" }]);
expect(mockTypesSourceFn({}, "cz")).toEqual([
{ name: "cz-git", value: "cz-git", index: 3, score: 4 }
]);
expect(mockTypesSourceFn({}, "em")).toEqual([
{ name: "empty", value: false, index: 0, score: 4 }
]);
expect(mockTypesSourceFn({}, "cu")).toEqual([
{ name: "custom", value: "___CUSTOM___", index: 1, score: 4 }
]);
expect(mockTypesSourceFn({}, "aaa")).toEqual([]);
});
});
Loading

0 comments on commit 3cb13d7

Please sign in to comment.