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