blob: 729194826e7ab7cc1381803b0bfc02c2b716a898 [file] [edit]
// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import fuzzysort from 'fuzzysort';
export interface FuzzySegment {
readonly matching: boolean;
readonly value: string;
}
export interface FuzzyResult<T> {
readonly item: T;
readonly segments: readonly FuzzySegment[];
// Relevance score of the match, higher is better. Exact matches score higher
// than fuzzy ones. Empty search terms produce a uniform score of 1.
readonly score: number;
}
export interface FuzzyMultiResult<T> {
readonly item: T;
readonly segments: readonly (readonly FuzzySegment[])[];
// Relevance score of the match, higher is better. Exact matches score higher
// than fuzzy ones. Empty search terms produce a uniform score of 1.
readonly score: number;
}
export type KeyLookup<T> = (x: T) => string;
/**
* Performs character-subsequence fuzzy matching over a list of items using
* fuzzysort.
*
* Accepts either a single key lookup function or an array of key lookup
* functions. When multiple key lookups are provided, matches across any key
* will be returned with per-key highlight segments and a unified match score.
*
* An empty `searchTerm` returns all items in original order with uniform scores
* of 1.
*
* @param items List of candidate items to search through.
* @param keyLookup Single key extraction function or array of key extraction
* functions.
* @param searchTerm Search query text to match against item keys.
* @returns Array of matching items ordered by relevance score, with highlight
* segments and match scores.
*/
export function fuzzySearch<T>(
items: readonly T[],
keyLookup: readonly KeyLookup<T>[],
searchTerm: string,
): FuzzyMultiResult<T>[];
export function fuzzySearch<T>(
items: readonly T[],
keyLookup: KeyLookup<T>,
searchTerm: string,
): FuzzyResult<T>[];
export function fuzzySearch<T>(
items: readonly T[],
keyLookup: KeyLookup<T> | readonly KeyLookup<T>[],
searchTerm: string,
): FuzzyMultiResult<T>[] | FuzzyResult<T>[] {
if (typeof keyLookup === 'function') {
// Single key: delegate to multi-key search and unwrap segments.
const result = fuzzySearchMultiKey(items, [keyLookup], searchTerm);
return result.map((res) => ({
item: res.item,
segments: res.segments[0],
score: res.score,
}));
} else {
// Multi-key: return multi-key search results directly.
return fuzzySearchMultiKey(items, keyLookup, searchTerm);
}
}
function fuzzySearchMultiKey<T>(
items: readonly T[],
keyLookup: readonly KeyLookup<T>[],
searchTerm: string,
): FuzzyMultiResult<T>[] {
if (searchTerm === '') {
return items.map((item) => {
const segments = keyLookup.map((lookup) => [
{matching: false, value: lookup(item)},
]);
return {item, segments, score: 1};
});
}
const results = fuzzysort.go(searchTerm, items, {
keys: keyLookup,
});
return results.map((result) => {
const segments = keyLookup.map((lookup, i) => {
const text = lookup(result.obj);
const keyMatch = result[i];
const indexes =
keyMatch?.indexes !== undefined
? keyMatch.indexes.toSorted((a, b) => a - b)
: [];
return indiciesToSegments(indexes, text);
});
return {
item: result.obj,
segments,
score: result.score,
};
});
}
// Given a list of indicies of matching chars, and the original value, produce
// a list of match/nomatch segments.
function indiciesToSegments(indicies: number[], text: string): FuzzySegment[] {
const segments: FuzzySegment[] = [];
let nextIndex = 0;
let match = '';
for (const i of indicies) {
if (nextIndex < i) {
// If we had a match segment from before, add it now.
if (match !== '') {
segments.push({matching: true, value: match});
match = '';
}
// Missed some indicies - wrap them up into a nomatch segment.
segments.push({matching: false, value: text.slice(nextIndex, i)});
}
// Append this matching char to the previous match.
match += text[i];
nextIndex = i + 1;
}
// Add any lingering match segment.
if (match !== '') {
segments.push({matching: true, value: match});
}
// Add final nomatch segment if there is anything left.
if (nextIndex < text.length) {
segments.push({matching: false, value: text.slice(nextIndex)});
}
return segments;
}