| // 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; |
| } |