blob: 9c6a11cefd6537f58b0bc00c9820e12c1a3f20e1 [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'
| 'pid'
| 'tid'
| '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',
];
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
/**
* Interface impleemnted by all expression types that can evaluate a log.
*/
export interface FilterExpression {
/**
* Whether or not the log should be filtered.
* @param el the element containing the log data to evalute
*/
accepts(el: Element): boolean;
/**
* Whether or not the filter evalutes nothing.
*/
isEmpty(): boolean;
}
/**
* A parsed "and" expression.
*/
export class AndExpression implements FilterExpression {
constructor(private items: FilterExpression[]) { }
accepts(el: Element): boolean {
return this.isEmpty() || this.items.every((item) => item.accepts(el));
}
isEmpty(): boolean {
return this.items.length === 0;
}
}
/**
* A parsed "or" expression.
*/
export class OrExpression implements FilterExpression {
constructor(private items: FilterExpression[]) { }
accepts(el: Element): boolean {
return this.isEmpty() || this.items.some((item) => item.accepts(el));
}
isEmpty(): boolean {
return this.items.length === 0;
}
}
/**
* A parsed "not" expression.
*/
export class NotExpression implements FilterExpression {
constructor(private item: FilterExpression) { }
accepts(el: Element): boolean {
return !this.item.accepts(el);
}
isEmpty(): boolean {
return this.item.isEmpty();
}
}
/**
* Represents a single filter and provides the necessary functionality for evaluating logs.
*/
export class Filter implements FilterExpression {
// 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 value that the operator will be applied to.
readonly value: boolean | number | string | RegExp;
constructor(args: {
category: Category,
subCategory: string | undefined,
operator: Operator,
value: boolean | number | string | RegExp;
}) {
this.category = args.category;
this.subCategory = args.subCategory;
this.operator = args.operator;
this.value = args.value;
// Contains operator works without case sensitivity.
if (this.operator === 'contains' && typeof this.value === 'string') {
this.value = this.value.toLowerCase();
}
}
public accepts(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);
case 'pid':
case 'tid':
return this.evalPidTid(this.category, el);
}
}
isEmpty(): boolean {
return false;
}
private evalSeverity(el: Element): boolean {
const query = el.getAttribute('data-severity');
if (!query) {
return false;
}
const severity = this.value as string;
switch (this.operator) {
// Contains on severity works as "minimum severity" so the same as ">=".
case 'contains':
case 'greaterEq':
return severityMap[query] >= severityMap[severity];
case 'lessEq':
return severityMap[query] <= severityMap[severity];
case 'less':
return severityMap[query] < severityMap[severity];
case 'greater':
return severityMap[query] > severityMap[severity];
case 'equal':
return severityMap[query] === severityMap[severity];
}
}
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 (evalStringOp(tag!, this.operator, this.value as (string | RegExp))) {
return true;
}
}
return false;
}
evalPidTid(category: 'pid' | 'tid', el: Element): boolean {
const query = el.getAttribute(`data-${category}`);
if (!query) {
return false;
}
return evalNumberOp(parseInt(query), this.operator, this.value as number);
}
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.value === 'object' && this.value.constructor.name === 'RegExp') {
return evalRegex(query, this.operator, this.value);
} else if (typeof this.value === 'string') {
return evalStringOp(query, this.operator, this.value as string);
} else if (typeof this.value === 'boolean') {
return evalBoolOp(query === 'true', this.operator, this.value as boolean);
} else if (typeof this.value === 'number') {
return evalNumberOp(parseInt(query), this.operator, this.value as number);
} else {
console.log(`Unrecognized type for a value: ${typeof this.value}`);
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;
}
return evalStringOp(query!, this.operator, this.value as (string | RegExp));
}
};
/**
* 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 | RegExp): boolean {
if (typeof rhs !== 'string') {
return evalRegex(lhs, op, rhs);
}
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;
}
}
/**
* Matches the given `lhs` value with a regular expression with the given operator.
*
* @param lhs the string to match
* @param op operator (equals or contains)
* @param rhs the regular expression to match against
* @return true if the operator is contains and the string matches. If the operator is equals, then
* the matched string also must be equal to `lhs` for the return to be true.
*/
function evalRegex(lhs: string, op: Operator, rhs: RegExp): boolean {
const m = lhs.match(rhs);
switch (op) {
case 'contains':
return m !== null;
case 'equal':
return m !== null && m[0] === lhs;
default:
return false;
}
}
/**
* 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 expression = parser.parse(
// eslint-disable-next-line @typescript-eslint/naming-convention
text, { OrExpression, AndExpression, NotExpression, Filter: Filter }) as FilterExpression;
return {
ok: true,
value: expression,
};
} catch (error) {
return { ok: false, error: `${error}` };
}
}