| // Copyright 2022 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| // @ts-ignore: auto-generated at build time (run npm run build/npm test/npm bc) |
| import * as parser from '../parsing/filters.pegjs'; |
| |
| // NOTE: this type must match the definitions in the PEG.js parser. |
| export type Category |
| = 'any' |
| | 'moniker' |
| | 'tag' |
| | 'package-name' |
| | 'manifest' |
| | 'message' |
| | 'severity' |
| | 'custom'; |
| |
| // NOTE: this type must match the definitions in the PEG.js parser. |
| export type Operator |
| = 'contains' |
| | 'equal' |
| | 'greater' |
| | 'greaterEq' |
| | 'less' |
| | 'lessEq'; |
| |
| const severityMap: Record<string, number> = { |
| trace: 0, |
| debug: 1, |
| info: 2, |
| warn: 3, |
| error: 4, |
| fatal: 5, |
| }; |
| |
| // Categories which the `any` filter will apply to. |
| const anyCategories: Category[] = [ |
| 'moniker', |
| 'tag', |
| 'package-name', |
| 'manifest', |
| 'message', |
| ]; |
| |
| // TODO(fxbug.dev/99579): support more nesting and complete logical expressions. |
| export class FilterExpression { |
| readonly groups: Filter[][]; |
| constructor(groups: Filter[][]) { |
| this.groups = groups; |
| } |
| |
| /** |
| * Evaluates the filter groups of this expression. |
| * This will be true iff there exists a filter `j` in `groups[i][j]` that evaluates to true |
| * for all `i`. |
| * |
| * @param el the element to which the filter will be applied |
| * @returns true if the set of filters accepts the given element properties |
| */ |
| public accepts(el: Element): boolean { |
| return this.groups.every((filters) => filters.some((filter) => filter.accepts(el))); |
| } |
| |
| /** |
| * Whether the filter is empty, which means it's a no-op. |
| * |
| * @returns true if there's no filters to be applied. |
| */ |
| public isEmpty(): boolean { |
| return this.groups.length === 0; |
| } |
| } |
| |
| export class Filter { |
| // The category that this filter applies to. |
| readonly category: Category; |
| |
| // The sub category that this filter applies to. This is only used for custom keys. |
| readonly subCategory: string | undefined; |
| |
| // The operator invoked by this filter. |
| readonly operator: Operator; |
| |
| // The values that the operator will be applied to. |
| readonly values: boolean[] | number[] | string[]; |
| |
| // Whether the result of this filter should be negated or not. |
| readonly not: boolean; |
| |
| constructor(args: { |
| category: Category, |
| subCategory: string | undefined, |
| operator: Operator, |
| values: boolean[] | number[] | string[]; |
| not: boolean, |
| }) { |
| this.category = args.category; |
| this.subCategory = args.subCategory; |
| this.operator = args.operator; |
| this.values = args.values; |
| this.not = args.not; |
| |
| // Contains operator works without case sensitivity. |
| if (this.operator === 'contains' && typeof this.values[0] === 'string') { |
| // @ts-ignore: this.values must be of the same type and the check above validates that, |
| // so toLowerCase exists. |
| this.values = this.values.map((value) => value.toLowerCase()); |
| } |
| } |
| |
| public accepts(el: Element): boolean { |
| if (this.values.length === 0) { |
| return true; |
| } |
| let result = this.partialEval(el); |
| if (this.not) { |
| return !result; |
| } |
| return result; |
| } |
| |
| private partialEval(el: Element): boolean { |
| switch (this.category) { |
| case 'any': |
| return this.evalAny(el); |
| case 'custom': |
| return this.evalCustom(el); |
| case 'severity': |
| return this.evalSeverity(el); |
| case 'tag': |
| return this.evalTag(el); |
| case 'manifest': |
| case 'message': |
| case 'moniker': |
| case 'package-name': |
| return this.evalGeneral(el); |
| } |
| } |
| |
| private evalSeverity(el: Element): boolean { |
| const query = el.getAttribute('data-severity'); |
| if (!query) { |
| return false; |
| } |
| return this.values.length === 0 |
| || this.values.some((value) => { |
| switch (this.operator) { |
| // Contains on severity works as "minimum severity" so the same as ">=". |
| case 'contains': |
| case 'greaterEq': |
| return severityMap[query] >= severityMap[value as string]; |
| case 'lessEq': |
| return severityMap[query] <= severityMap[value as string]; |
| case 'less': |
| return severityMap[query] < severityMap[value as string]; |
| case 'greater': |
| return severityMap[query] > severityMap[value as string]; |
| case 'equal': |
| return severityMap[query] === severityMap[value as string]; |
| } |
| }); |
| } |
| |
| private evalAny(el: Element): boolean { |
| return anyCategories.some((category) => { |
| if (category === 'tag') { |
| return this.evalTag(el); |
| } else { |
| return this.evalGeneralHelper(category, el); |
| } |
| }) || el.getAttributeNames().some((attributeName) => { |
| return attributeName.startsWith('data-custom-') && this.evalCustomHelper(attributeName, el); |
| }); |
| } |
| |
| private evalTag(el: Element): boolean { |
| const totalTagsStr = el.getAttribute('data-tags'); |
| if (!totalTagsStr) { |
| return false; |
| } |
| const totalTags = parseInt(totalTagsStr); |
| for (let i = 0; i < totalTags; i++) { |
| const tag = el.getAttribute(`data-tag-${i}`); |
| if (this.values.some((value) => evalStringOp(tag!, this.operator, value as string))) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| private evalCustom(el: Element): boolean { |
| if (this.subCategory === undefined) { |
| return false; |
| } |
| return this.evalCustomHelper(`data-custom-${this.subCategory}`, el); |
| } |
| |
| private evalCustomHelper(attributeName: string, el: Element) { |
| const query = el.getAttribute(attributeName); |
| if (!query) { |
| return false; |
| } |
| if (typeof this.values[0] === 'string') { |
| return this.values.some( |
| (value) => evalStringOp(query, this.operator, value as string)); |
| } else if (typeof this.values[0] === 'boolean') { |
| return this.values.some( |
| (value) => evalBoolOp(query === 'true', this.operator, value as boolean)); |
| } else if (typeof this.values[0] === 'number') { |
| return this.values.some( |
| (value) => evalNumberOp(parseInt(query), this.operator, value as number)); |
| } else { |
| console.log(`Unrecognized type for a value: ${typeof this.values[0]}`); |
| return false; |
| } |
| } |
| |
| private evalGeneral(el: Element): boolean { |
| return this.evalGeneralHelper(this.category, el); |
| } |
| |
| private evalGeneralHelper(category: Category, el: Element): boolean { |
| const query = el.getAttribute(`data-${category}`); |
| if (!query) { |
| return false; |
| } |
| // @ts-ignore: the parser already checked that `value` is of type string. |
| return this.values.some((value) => evalStringOp(query!, this.operator, value)); |
| } |
| }; |
| |
| /** |
| * Executes a string logical operation on two values. |
| * |
| * @param lhs left hand side value of the operation. |
| * @param op the operation to apply for both strings. |
| * @param rhs right hand side value of the operation. |
| * @returns the result of the operation. |
| */ |
| function evalStringOp(lhs: string, op: Operator, rhs: string): boolean { |
| switch (op) { |
| case 'contains': |
| return lhs.toLowerCase().includes(rhs); |
| case 'equal': |
| return lhs === rhs; |
| default: |
| return false; |
| } |
| } |
| |
| /** |
| * Executes a comparison operation on two booleans. |
| * |
| * @param lhs left hand side value of the operation. |
| * @param op the operation to apply for both booleans. |
| * @param rhs right hand side value of the operation. |
| * @returns the result of the operation. |
| */ |
| function evalBoolOp(lhs: boolean, op: Operator, rhs: boolean): boolean { |
| switch (op) { |
| case 'contains': |
| case 'equal': |
| return lhs === rhs; |
| default: |
| return false; |
| } |
| } |
| |
| /** |
| * Executes a comparison operation on two numbers. |
| * |
| * @param lhs left hand side value of the operation. |
| * @param op the operation to apply to both booleans. |
| * @param rhs right hand side of the operation. |
| * @returns the result of the operation. |
| */ |
| function evalNumberOp(lhs: number, op: Operator, rhs: number): boolean { |
| switch (op) { |
| case 'contains': |
| case 'equal': |
| // This allows to perform comparisons such as "3" == 3 allowing to remove the need of doing |
| // parseInt. |
| // eslint-disable-next-line eqeqeq |
| return lhs == rhs; |
| case 'greater': |
| return lhs > rhs; |
| case 'greaterEq': |
| return lhs >= rhs; |
| case 'less': |
| return lhs < rhs; |
| case 'lessEq': |
| return lhs <= rhs; |
| } |
| } |
| |
| |
| // NOTE: this must match the response returned by the PEG.js parser. |
| interface ParsedFilter { |
| category: string, |
| subCategory: string | undefined, |
| operator: string, |
| values: boolean[] | number[] | string[], |
| not: boolean, |
| } |
| |
| export type Result<T, E = Error> = |
| | { ok: true; value: T } |
| | { ok: false; error: E }; |
| |
| /** |
| * Parses a string specifying a filter expression into a structured expression that |
| * can be evaluated. |
| * |
| * @param text input text to parse |
| * @returns a result containing the parsed filter or an error message if the |
| * filter couldn't be parsed |
| */ |
| export function parseFilter(text: string): Result<FilterExpression, string> { |
| try { |
| const groups = parser.parse(text) as ParsedFilter[][]; |
| return { |
| ok: true, |
| value: new FilterExpression(groups.map((group) => { |
| return group.map((parsedFilter) => { |
| return new Filter({ |
| category: parsedFilter.category.toLowerCase() as Category, |
| subCategory: parsedFilter.subCategory, |
| operator: parsedFilter.operator as Operator, |
| values: parsedFilter.values, |
| not: parsedFilter.not, |
| }); |
| }); |
| })), |
| }; |
| } catch (error) { |
| return { ok: false, error: `${error}` }; |
| } |
| } |