blob: 016942bae0effd9ebdaf48ee68c012fdcb5d1bcd [file] [log] [blame]
// 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}` };
}
}