xref: /aosp_15_r20/external/perfetto/ui/src/base/fuzzy.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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