blob: 7a67a6c7863c940d1150703cd89cf6e5611b5758 [file] [log] [blame]
// 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 {Fzf} from 'fzf';
import {SyncOptionsTuple} from 'fzf/dist/types/finders';
export interface FuzzySegment {
matching: boolean;
value: string;
}
export interface FuzzyResult<T> {
item: T;
segments: FuzzySegment[];
}
export type KeyLookup<T> = (x: T) => string;
// Finds approx matching in arbitrary lists of items.
export class FuzzyFinder<T> {
private readonly fzf: Fzf<ReadonlyArray<T>>;
// Because we operate on arbitrary lists, a key lookup function is required to
// so we know which part of the list is to be be searched. It should return
// the relevant search string for each item.
constructor(
items: ReadonlyArray<T>,
private readonly keyLookup: KeyLookup<T>,
) {
// NOTE(stevegolton): This type assertion because FZF appears to be very
// fussy about its input types.
const options = [{selector: keyLookup}] as SyncOptionsTuple<T>;
this.fzf = new Fzf<ReadonlyArray<T>>(items, ...options);
}
// Return a list of items that match any of the search terms.
find(searchTerm: string): FuzzyResult<T>[] {
return this.fzf.find(searchTerm).map((m) => {
const normalisedTerm = this.keyLookup(m.item);
return {
item: m.item,
segments: indiciesToSegments(
Array.from(m.positions).sort((a, b) => a - b),
normalisedTerm,
),
};
});
}
}
// 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;
}
// Evaluate whether |searchTerm| is found in |text|.
// |indicies| is an array of numbers the same length as |searchTerm|, into which
// we place the indicies of the matching chars in |text|.
function match(searchTerm: string, text: string, indicies: number[]): boolean {
let j = 0; // index into the searchTerm.
let success: boolean = true;
// For each char of the searchTerm...
for (let i = 0; i < searchTerm.length; ++i) {
const char = searchTerm[i].toLowerCase();
// ...advance the text index until we find the char.
for (; j < text.length; ++j) {
// If we find it add it to the segment and move on.
if (text[j].toLowerCase() === char) {
indicies[i] = j;
break;
}
}
// Failed to find searchTerm[i] in text: give up.
if (j === text.length) {
success = false;
break;
}
++j;
}
return success;
}
export interface FuzzyMatch {
matches: boolean;
segments: FuzzySegment[];
}
// Fuzzy match a single piece of text against several search terms.
// If any of the terms match, the result of the match is true.
export function fuzzyMatch(
text: string,
...searchTerms: ReadonlyArray<string>
): FuzzyMatch {
for (const searchTerm of searchTerms) {
const indicies: number[] = new Array(searchTerm.length);
if (match(searchTerm, text, indicies)) {
const segments = indiciesToSegments(indicies, text);
return {
matches: true,
segments,
};
}
}
return {
matches: false,
segments: [],
};
}