xref: /aosp_15_r20/external/pigweed/pw_web/log-viewer/src/utils/log-filter/log-filter.ts (revision 61c4878ac05f98d0ceed94b57d316916de578985)
1// Copyright 2023 The Pigweed Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License"); you may not
4// use this file except in compliance with the License. You may obtain a copy of
5// the License at
6//
7//     https://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, WITHOUT
11// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12// License for the specific language governing permissions and limitations under
13// the License.
14
15import { LogEntry } from '../../shared/interfaces';
16import { FilterCondition, ConditionType } from './log-filter.models';
17
18export class LogFilter {
19  /**
20   * Generates a structured representation of filter conditions which can be
21   * used to filter log entries.
22   *
23   * @param {string} searchQuery - The search query string provided.
24   * @returns {function[]} An array of filter functions, each representing a
25   *   set of conditions grouped by logical operators, for filtering log
26   *   entries.
27   */
28  static parseSearchQuery(searchQuery: string): FilterCondition[] {
29    const filters: FilterCondition[] = [];
30    const orGroups = searchQuery.split(/\s*\|\s*/);
31
32    for (let i = 0; i < orGroups.length; i++) {
33      let orGroup = orGroups[i];
34
35      if (orGroup.includes('(') && !orGroup.includes(')')) {
36        let j = i + 1;
37        while (j < orGroups.length && !orGroups[j].includes(')')) {
38          orGroup += ` | ${orGroups[j]}`;
39          j++;
40        }
41
42        if (j < orGroups.length) {
43          orGroup += ` | ${orGroups[j]}`;
44          i = j;
45        }
46      }
47
48      const andConditions = orGroup.match(
49        /\([^()]*\)|"[^"]+"|[^\s:]+:[^\s]+|[^\s]+/g,
50      );
51
52      const andFilters: FilterCondition[] = [];
53
54      if (andConditions) {
55        for (const condition of andConditions) {
56          if (condition.startsWith('(') && condition.endsWith(')')) {
57            const nestedConditions = condition.slice(1, -1).trim();
58            andFilters.push(...this.parseSearchQuery(nestedConditions));
59          } else if (condition.startsWith('"') && condition.endsWith('"')) {
60            const exactPhrase = condition.slice(1, -1).trim();
61            andFilters.push({
62              type: ConditionType.ExactPhraseSearch,
63              exactPhrase,
64            });
65          } else if (condition.startsWith('!')) {
66            const column = condition.slice(1, condition.indexOf(':'));
67            const value = condition.slice(condition.indexOf(':') + 1);
68            andFilters.push({
69              type: ConditionType.NotExpression,
70              expression: {
71                type: ConditionType.ColumnSearch,
72                column,
73                value,
74              },
75            });
76          } else if (condition.endsWith(':')) {
77            const column = condition.slice(0, condition.indexOf(':'));
78            andFilters.push({
79              type: ConditionType.ColumnSearch,
80              column,
81            });
82          } else if (condition.includes(':')) {
83            const column = condition.slice(0, condition.indexOf(':'));
84            const value = condition.slice(condition.indexOf(':') + 1);
85            andFilters.push({
86              type: ConditionType.ColumnSearch,
87              column,
88              value,
89            });
90          } else {
91            andFilters.push({
92              type: ConditionType.StringSearch,
93              searchString: condition,
94            });
95          }
96        }
97      }
98
99      if (andFilters.length > 0) {
100        if (andFilters.length === 1) {
101          filters.push(andFilters[0]);
102        } else {
103          filters.push({
104            type: ConditionType.AndExpression,
105            expressions: andFilters,
106          });
107        }
108      }
109    }
110
111    if (filters.length === 0) {
112      filters.push({
113        type: ConditionType.StringSearch,
114        searchString: '',
115      });
116    }
117
118    if (filters.length > 1) {
119      return [
120        {
121          type: ConditionType.OrExpression,
122          expressions: filters,
123        },
124      ];
125    }
126
127    return filters;
128  }
129
130  /**
131   * Takes a condition node, which represents a specific filter condition, and
132   * recursively generates a filter function that can be applied to log
133   * entries.
134   *
135   * @param {FilterCondition} condition - A filter condition to convert to a
136   *   function.
137   * @returns {function} A function for filtering log entries based on the
138   *   input condition and its logical operators.
139   */
140  static createFilterFunction(
141    condition: FilterCondition,
142  ): (logEntry: LogEntry) => boolean {
143    switch (condition.type) {
144      case ConditionType.StringSearch:
145        return (logEntry) =>
146          this.checkStringInColumns(logEntry, condition.searchString);
147      case ConditionType.ExactPhraseSearch:
148        return (logEntry) =>
149          this.checkExactPhraseInColumns(logEntry, condition.exactPhrase);
150      case ConditionType.ColumnSearch:
151        return (logEntry) =>
152          this.checkColumn(logEntry, condition.column, condition.value);
153      case ConditionType.NotExpression: {
154        const innerFilter = this.createFilterFunction(condition.expression);
155        return (logEntry) => !innerFilter(logEntry);
156      }
157      case ConditionType.AndExpression: {
158        const andFilters = condition.expressions.map((expr) =>
159          this.createFilterFunction(expr),
160        );
161        return (logEntry) => andFilters.every((filter) => filter(logEntry));
162      }
163      case ConditionType.OrExpression: {
164        const orFilters = condition.expressions.map((expr) =>
165          this.createFilterFunction(expr),
166        );
167        return (logEntry) => orFilters.some((filter) => filter(logEntry));
168      }
169      default:
170        // Return a filter that matches all entries
171        return () => true;
172    }
173  }
174
175  /**
176   * Checks if the column exists in a log entry and then performs a value
177   * search on the column's value.
178   *
179   * @param {LogEntry} logEntry - The log entry to be searched.
180   * @param {string} column - The name of the column (log entry field) to be
181   *   checked for filtering.
182   * @param {string} value - An optional string that represents the value used
183   *   for filtering.
184   * @returns {boolean} True if the specified column exists in the log entry,
185   *   or if a value is provided, returns true if the value matches a
186   *   substring of the column's value (case-insensitive).
187   */
188  private static checkColumn(
189    logEntry: LogEntry,
190    column: string,
191    value?: string,
192  ): boolean {
193    const field = logEntry.fields.find((field) => field.key === column);
194    if (!field) return false;
195
196    if (value === undefined) {
197      return true;
198    }
199
200    const searchRegex = new RegExp(value, 'i');
201    return searchRegex.test(field.value.toString());
202  }
203
204  /**
205   * Checks if the provided search string exists in any of the log entry
206   * columns (excluding `level`).
207   *
208   * @param {LogEntry} logEntry - The log entry to be searched.
209   * @param {string} searchString - The search string to be matched against
210   *   the log entry fields.
211   * @returns {boolean} True if the search string is found in any of the log
212   *   entry fields, otherwise false.
213   */
214  private static checkStringInColumns(
215    logEntry: LogEntry,
216    searchString: string,
217  ): boolean {
218    const escapedSearchString = this.escapeRegEx(searchString);
219    const columnsToSearch = logEntry.fields.filter(
220      (field) => field.key !== 'level',
221    );
222    return columnsToSearch.some((field) =>
223      new RegExp(escapedSearchString, 'i').test(field.value.toString()),
224    );
225  }
226
227  /**
228   * Checks if the exact phrase exists in any of the log entry columns
229   * (excluding `level`).
230   *
231   * @param {LogEntry} logEntry - The log entry to be searched.
232   * @param {string} exactPhrase - The exact phrase to search for within the
233   *   log entry columns.
234   * @returns {boolean} True if the exact phrase is found in any column,
235   *   otherwise false.
236   */
237  private static checkExactPhraseInColumns(
238    logEntry: LogEntry,
239    exactPhrase: string,
240  ): boolean {
241    const escapedExactPhrase = this.escapeRegEx(exactPhrase);
242    const searchRegex = new RegExp(escapedExactPhrase, 'i');
243    const columnsToSearch = logEntry.fields.filter(
244      (field) => field.key !== 'level',
245    );
246    return columnsToSearch.some((field) =>
247      searchRegex.test(field.value.toString()),
248    );
249  }
250
251  private static escapeRegEx(text: string) {
252    return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
253  }
254}
255