1*6dbdd20aSAndroid Build Coastguard Worker// Copyright (C) 2023 The Android Open Source Project 2*6dbdd20aSAndroid Build Coastguard Worker// 3*6dbdd20aSAndroid Build Coastguard Worker// Licensed under the Apache License, Version 2.0 (the "License"); 4*6dbdd20aSAndroid Build Coastguard Worker// you may not use this file except in compliance with the License. 5*6dbdd20aSAndroid Build Coastguard Worker// You may obtain a copy of the License at 6*6dbdd20aSAndroid Build Coastguard Worker// 7*6dbdd20aSAndroid Build Coastguard Worker// http://www.apache.org/licenses/LICENSE-2.0 8*6dbdd20aSAndroid Build Coastguard Worker// 9*6dbdd20aSAndroid Build Coastguard Worker// Unless required by applicable law or agreed to in writing, software 10*6dbdd20aSAndroid Build Coastguard Worker// distributed under the License is distributed on an "AS IS" BASIS, 11*6dbdd20aSAndroid Build Coastguard Worker// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12*6dbdd20aSAndroid Build Coastguard Worker// See the License for the specific language governing permissions and 13*6dbdd20aSAndroid Build Coastguard Worker// limitations under the License. 14*6dbdd20aSAndroid Build Coastguard Worker 15*6dbdd20aSAndroid Build Coastguard Workerimport {byLengthAsc, Fzf} from 'fzf'; 16*6dbdd20aSAndroid Build Coastguard Workerimport {SyncOptionsTuple} from 'fzf/dist/types/finders'; 17*6dbdd20aSAndroid Build Coastguard Worker 18*6dbdd20aSAndroid Build Coastguard Workerexport interface FuzzySegment { 19*6dbdd20aSAndroid Build Coastguard Worker matching: boolean; 20*6dbdd20aSAndroid Build Coastguard Worker value: string; 21*6dbdd20aSAndroid Build Coastguard Worker} 22*6dbdd20aSAndroid Build Coastguard Worker 23*6dbdd20aSAndroid Build Coastguard Workerexport interface FuzzyResult<T> { 24*6dbdd20aSAndroid Build Coastguard Worker item: T; 25*6dbdd20aSAndroid Build Coastguard Worker segments: FuzzySegment[]; 26*6dbdd20aSAndroid Build Coastguard Worker} 27*6dbdd20aSAndroid Build Coastguard Worker 28*6dbdd20aSAndroid Build Coastguard Workerexport type KeyLookup<T> = (x: T) => string; 29*6dbdd20aSAndroid Build Coastguard Worker 30*6dbdd20aSAndroid Build Coastguard Worker// Finds approx matching in arbitrary lists of items. 31*6dbdd20aSAndroid Build Coastguard Workerexport class FuzzyFinder<T> { 32*6dbdd20aSAndroid Build Coastguard Worker private readonly fzf: Fzf<ReadonlyArray<T>>; 33*6dbdd20aSAndroid Build Coastguard Worker // Because we operate on arbitrary lists, a key lookup function is required to 34*6dbdd20aSAndroid Build Coastguard Worker // so we know which part of the list is to be be searched. It should return 35*6dbdd20aSAndroid Build Coastguard Worker // the relevant search string for each item. 36*6dbdd20aSAndroid Build Coastguard Worker constructor( 37*6dbdd20aSAndroid Build Coastguard Worker items: ReadonlyArray<T>, 38*6dbdd20aSAndroid Build Coastguard Worker private readonly keyLookup: KeyLookup<T>, 39*6dbdd20aSAndroid Build Coastguard Worker ) { 40*6dbdd20aSAndroid Build Coastguard Worker // NOTE(stevegolton): This type assertion because FZF appears to be very 41*6dbdd20aSAndroid Build Coastguard Worker // fussy about its input types. 42*6dbdd20aSAndroid Build Coastguard Worker const options = [ 43*6dbdd20aSAndroid Build Coastguard Worker {selector: keyLookup, tiebreakers: [byLengthAsc]}, 44*6dbdd20aSAndroid Build Coastguard Worker ] as SyncOptionsTuple<T>; 45*6dbdd20aSAndroid Build Coastguard Worker this.fzf = new Fzf<ReadonlyArray<T>>(items, ...options); 46*6dbdd20aSAndroid Build Coastguard Worker } 47*6dbdd20aSAndroid Build Coastguard Worker 48*6dbdd20aSAndroid Build Coastguard Worker // Return a list of items that match any of the search terms. 49*6dbdd20aSAndroid Build Coastguard Worker find(searchTerm: string): FuzzyResult<T>[] { 50*6dbdd20aSAndroid Build Coastguard Worker return this.fzf.find(searchTerm).map((m) => { 51*6dbdd20aSAndroid Build Coastguard Worker const normalisedTerm = this.keyLookup(m.item); 52*6dbdd20aSAndroid Build Coastguard Worker return { 53*6dbdd20aSAndroid Build Coastguard Worker item: m.item, 54*6dbdd20aSAndroid Build Coastguard Worker segments: indiciesToSegments( 55*6dbdd20aSAndroid Build Coastguard Worker Array.from(m.positions).sort((a, b) => a - b), 56*6dbdd20aSAndroid Build Coastguard Worker normalisedTerm, 57*6dbdd20aSAndroid Build Coastguard Worker ), 58*6dbdd20aSAndroid Build Coastguard Worker }; 59*6dbdd20aSAndroid Build Coastguard Worker }); 60*6dbdd20aSAndroid Build Coastguard Worker } 61*6dbdd20aSAndroid Build Coastguard Worker} 62*6dbdd20aSAndroid Build Coastguard Worker 63*6dbdd20aSAndroid Build Coastguard Worker// Given a list of indicies of matching chars, and the original value, produce 64*6dbdd20aSAndroid Build Coastguard Worker// a list of match/nomatch segments. 65*6dbdd20aSAndroid Build Coastguard Workerfunction indiciesToSegments(indicies: number[], text: string): FuzzySegment[] { 66*6dbdd20aSAndroid Build Coastguard Worker const segments: FuzzySegment[] = []; 67*6dbdd20aSAndroid Build Coastguard Worker let nextIndex = 0; 68*6dbdd20aSAndroid Build Coastguard Worker let match = ''; 69*6dbdd20aSAndroid Build Coastguard Worker for (const i of indicies) { 70*6dbdd20aSAndroid Build Coastguard Worker if (nextIndex < i) { 71*6dbdd20aSAndroid Build Coastguard Worker // If we had a match segment from before, add it now. 72*6dbdd20aSAndroid Build Coastguard Worker if (match !== '') { 73*6dbdd20aSAndroid Build Coastguard Worker segments.push({matching: true, value: match}); 74*6dbdd20aSAndroid Build Coastguard Worker match = ''; 75*6dbdd20aSAndroid Build Coastguard Worker } 76*6dbdd20aSAndroid Build Coastguard Worker // Missed some indicies - wrap them up into a nomatch segment. 77*6dbdd20aSAndroid Build Coastguard Worker segments.push({matching: false, value: text.slice(nextIndex, i)}); 78*6dbdd20aSAndroid Build Coastguard Worker } 79*6dbdd20aSAndroid Build Coastguard Worker 80*6dbdd20aSAndroid Build Coastguard Worker // Append this matching char to the previous match. 81*6dbdd20aSAndroid Build Coastguard Worker match += text[i]; 82*6dbdd20aSAndroid Build Coastguard Worker nextIndex = i + 1; 83*6dbdd20aSAndroid Build Coastguard Worker } 84*6dbdd20aSAndroid Build Coastguard Worker 85*6dbdd20aSAndroid Build Coastguard Worker // Add any lingering match segment. 86*6dbdd20aSAndroid Build Coastguard Worker if (match !== '') { 87*6dbdd20aSAndroid Build Coastguard Worker segments.push({matching: true, value: match}); 88*6dbdd20aSAndroid Build Coastguard Worker } 89*6dbdd20aSAndroid Build Coastguard Worker 90*6dbdd20aSAndroid Build Coastguard Worker // Add final nomatch segment if there is anything left. 91*6dbdd20aSAndroid Build Coastguard Worker if (nextIndex < text.length) { 92*6dbdd20aSAndroid Build Coastguard Worker segments.push({matching: false, value: text.slice(nextIndex)}); 93*6dbdd20aSAndroid Build Coastguard Worker } 94*6dbdd20aSAndroid Build Coastguard Worker 95*6dbdd20aSAndroid Build Coastguard Worker return segments; 96*6dbdd20aSAndroid Build Coastguard Worker} 97*6dbdd20aSAndroid Build Coastguard Worker 98*6dbdd20aSAndroid Build Coastguard Worker// Evaluate whether |searchTerm| is found in |text|. 99*6dbdd20aSAndroid Build Coastguard Worker// |indicies| is an array of numbers the same length as |searchTerm|, into which 100*6dbdd20aSAndroid Build Coastguard Worker// we place the indicies of the matching chars in |text|. 101*6dbdd20aSAndroid Build Coastguard Workerfunction match(searchTerm: string, text: string, indicies: number[]): boolean { 102*6dbdd20aSAndroid Build Coastguard Worker let j = 0; // index into the searchTerm. 103*6dbdd20aSAndroid Build Coastguard Worker let success: boolean = true; 104*6dbdd20aSAndroid Build Coastguard Worker 105*6dbdd20aSAndroid Build Coastguard Worker // For each char of the searchTerm... 106*6dbdd20aSAndroid Build Coastguard Worker for (let i = 0; i < searchTerm.length; ++i) { 107*6dbdd20aSAndroid Build Coastguard Worker const char = searchTerm[i].toLowerCase(); 108*6dbdd20aSAndroid Build Coastguard Worker // ...advance the text index until we find the char. 109*6dbdd20aSAndroid Build Coastguard Worker for (; j < text.length; ++j) { 110*6dbdd20aSAndroid Build Coastguard Worker // If we find it add it to the segment and move on. 111*6dbdd20aSAndroid Build Coastguard Worker if (text[j].toLowerCase() === char) { 112*6dbdd20aSAndroid Build Coastguard Worker indicies[i] = j; 113*6dbdd20aSAndroid Build Coastguard Worker break; 114*6dbdd20aSAndroid Build Coastguard Worker } 115*6dbdd20aSAndroid Build Coastguard Worker } 116*6dbdd20aSAndroid Build Coastguard Worker 117*6dbdd20aSAndroid Build Coastguard Worker // Failed to find searchTerm[i] in text: give up. 118*6dbdd20aSAndroid Build Coastguard Worker if (j === text.length) { 119*6dbdd20aSAndroid Build Coastguard Worker success = false; 120*6dbdd20aSAndroid Build Coastguard Worker break; 121*6dbdd20aSAndroid Build Coastguard Worker } 122*6dbdd20aSAndroid Build Coastguard Worker 123*6dbdd20aSAndroid Build Coastguard Worker ++j; 124*6dbdd20aSAndroid Build Coastguard Worker } 125*6dbdd20aSAndroid Build Coastguard Worker 126*6dbdd20aSAndroid Build Coastguard Worker return success; 127*6dbdd20aSAndroid Build Coastguard Worker} 128*6dbdd20aSAndroid Build Coastguard Worker 129*6dbdd20aSAndroid Build Coastguard Workerexport interface FuzzyMatch { 130*6dbdd20aSAndroid Build Coastguard Worker matches: boolean; 131*6dbdd20aSAndroid Build Coastguard Worker segments: FuzzySegment[]; 132*6dbdd20aSAndroid Build Coastguard Worker} 133*6dbdd20aSAndroid Build Coastguard Worker 134*6dbdd20aSAndroid Build Coastguard Worker// Fuzzy match a single piece of text against several search terms. 135*6dbdd20aSAndroid Build Coastguard Worker// If any of the terms match, the result of the match is true. 136*6dbdd20aSAndroid Build Coastguard Workerexport function fuzzyMatch( 137*6dbdd20aSAndroid Build Coastguard Worker text: string, 138*6dbdd20aSAndroid Build Coastguard Worker ...searchTerms: ReadonlyArray<string> 139*6dbdd20aSAndroid Build Coastguard Worker): FuzzyMatch { 140*6dbdd20aSAndroid Build Coastguard Worker for (const searchTerm of searchTerms) { 141*6dbdd20aSAndroid Build Coastguard Worker const indicies: number[] = new Array(searchTerm.length); 142*6dbdd20aSAndroid Build Coastguard Worker if (match(searchTerm, text, indicies)) { 143*6dbdd20aSAndroid Build Coastguard Worker const segments = indiciesToSegments(indicies, text); 144*6dbdd20aSAndroid Build Coastguard Worker return { 145*6dbdd20aSAndroid Build Coastguard Worker matches: true, 146*6dbdd20aSAndroid Build Coastguard Worker segments, 147*6dbdd20aSAndroid Build Coastguard Worker }; 148*6dbdd20aSAndroid Build Coastguard Worker } 149*6dbdd20aSAndroid Build Coastguard Worker } 150*6dbdd20aSAndroid Build Coastguard Worker 151*6dbdd20aSAndroid Build Coastguard Worker return { 152*6dbdd20aSAndroid Build Coastguard Worker matches: false, 153*6dbdd20aSAndroid Build Coastguard Worker segments: [], 154*6dbdd20aSAndroid Build Coastguard Worker }; 155*6dbdd20aSAndroid Build Coastguard Worker} 156