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