blob: faa89c72b41c467767014e56e7e8934a2849656e [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.
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 items: T[];
private keyLookup: KeyLookup<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: ArrayLike<T>, keyLookup: KeyLookup<T>) {
this.items = Array.from(items);
this.keyLookup = keyLookup;
}
// Return a list of all items that match the serch term.
find(searchTerm: string): FuzzyResult<T>[] {
const result: FuzzyResult<T>[] = [];
const indicies: number[] = new Array(searchTerm.length);
for (const item of this.items) {
const key = this.keyLookup(item);
if (match(searchTerm, key, indicies)) {
const segments = indiciesToSegments(indicies, key);
result.push({item, segments});
}
}
return result;
}
}
// 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;
}