@player-ui/player
Version:
421 lines (334 loc) • 12 kB
text/typescript
import { SyncWaterfallHook, SyncBailHook } from "tapable-ts";
import { NestedError } from "ts-nested-error";
import { parseExpression } from "./parser";
import * as DEFAULT_EXPRESSION_HANDLERS from "./evaluator-functions";
import { isExpressionNode } from "./types";
import { isObjectExpression } from "./utils";
import type {
ExpressionNode,
BinaryOperator,
UnaryOperator,
ExpressionType,
ExpressionContext,
ExpressionHandler,
} from "./types";
/** a && b -- but handles short cutting if the first value is false */
const andandOperator: BinaryOperator = (ctx, a, b) => {
return ctx.evaluate(a) && ctx.evaluate(b);
};
andandOperator.resolveParams = false;
/** a || b -- but with short cutting if first value is true */
const ororOperator: BinaryOperator = (ctx, a, b) => {
return ctx.evaluate(a) || ctx.evaluate(b);
};
ororOperator.resolveParams = false;
const DEFAULT_BINARY_OPERATORS: Record<string, BinaryOperator> = {
// TODO: A lot of these functions used to do type coercion. Not sure if we want to keep that behavior or not.
"+": (a: any, b: any) => a + b,
"-": (a: any, b: any) => a - b,
"*": (a: any, b: any) => a * b,
"/": (a: any, b: any) => a / b,
"%": (a: any, b: any) => a % b,
// eslint-disable-next-line
"==": (a: any, b: any) => a == b,
// eslint-disable-next-line
"!=": (a: any, b: any) => a != b,
">": (a: any, b: any) => a > b,
">=": (a: any, b: any) => a >= b,
"<": (a: any, b: any) => a < b,
"<=": (a: any, b: any) => a <= b,
"&&": andandOperator,
"||": ororOperator,
"!==": (a: any, b: any) => a !== b,
"===": (a: any, b: any) => a === b,
// eslint-disable-next-line
"|": (a: any, b: any) => a | b,
// eslint-disable-next-line
"&": (a: any, b: any) => a & b,
"+=": (a: any, b: any) => a + b,
"-=": (a: any, b: any) => a - b,
// eslint-disable-next-line
"&=": (a: any, b: any) => a & b,
// eslint-disable-next-line
"|=": (a: any, b: any) => a | b,
};
const DEFAULT_UNARY_OPERATORS: Record<string, UnaryOperator> = {
"-": (a: any) => -a,
"+": (a: any) => Number(a),
"!": (a: any) => !a,
};
export interface HookOptions extends ExpressionContext {
/** Given an expression node */
resolveNode: (node: ExpressionNode) => any;
/** Enabling this flag skips calling the onError hook, and just throws errors back to the caller.
* The caller is responsible for handling the error.
*/
throwErrors?: boolean;
/** Whether expressions should be parsed strictly or not */
strict?: boolean;
}
export type ExpressionEvaluatorOptions = Omit<
HookOptions,
"resolveNode" | "evaluate"
>;
export type ExpressionEvaluatorFunction = (
exp: ExpressionType,
options?: ExpressionEvaluatorOptions,
) => any;
/**
* The expression evaluator is responsible for parsing and executing anything in the custom expression language
* */
export class ExpressionEvaluator {
private readonly vars: Record<string, any> = {};
public readonly hooks = {
/** Resolve an AST node for an expression to a value */
resolve: new SyncWaterfallHook<[any, ExpressionNode, HookOptions]>(),
/** Gets the options that will be passed in calls to the resolve hook */
resolveOptions: new SyncWaterfallHook<[HookOptions]>(),
/** Allows users to change the expression to be evaluated before processing */
beforeEvaluate: new SyncWaterfallHook<[ExpressionType, HookOptions]>(),
/**
* An optional means of handling an error in the expression execution
* Return true if handled, to stop propagation of the error
*/
onError: new SyncBailHook<[Error], true>(),
};
private readonly expressionsCache: Map<string, ExpressionNode> = new Map();
private readonly defaultHookOptions: HookOptions;
public readonly operators = {
binary: new Map(Object.entries(DEFAULT_BINARY_OPERATORS)),
unary: new Map(Object.entries(DEFAULT_UNARY_OPERATORS)),
expressions: new Map<string, ExpressionHandler<any, any>>(
Object.entries(DEFAULT_EXPRESSION_HANDLERS),
),
};
public reset(): void {
this.expressionsCache.clear();
}
constructor(defaultOptions: ExpressionEvaluatorOptions) {
this.defaultHookOptions = {
...defaultOptions,
evaluate: (expr) => this.evaluate(expr, this.defaultHookOptions),
resolveNode: (node: ExpressionNode) =>
this._execAST(node, this.defaultHookOptions),
};
this.hooks.resolve.tap("ExpressionEvaluator", this._resolveNode.bind(this));
this.evaluate = this.evaluate.bind(this);
}
public evaluate(
expr: ExpressionType,
options?: ExpressionEvaluatorOptions,
): any {
const resolvedOpts = this.hooks.resolveOptions.call({
...this.defaultHookOptions,
...options,
resolveNode: (node: ExpressionNode) => this._execAST(node, resolvedOpts),
});
let expression = this.hooks.beforeEvaluate.call(expr, resolvedOpts) ?? expr;
// Unwrap any returned expression type
// Since this could also be an object type, we need to recurse through it until we find the end
while (isObjectExpression(expression)) {
expression = expression.value;
}
// Check for literals
if (
typeof expression === "number" ||
typeof expression === "boolean" ||
expression === undefined ||
expression === null
) {
return expression;
}
// Skip doing anything with objects that are _actually_ just parsed expression nodes
if (isExpressionNode(expression)) {
return this._execAST(expression, resolvedOpts);
}
if (Array.isArray(expression)) {
return expression.reduce(
(_nothing, exp) => this.evaluate(exp, options),
null,
);
}
return this._execString(String(expression), resolvedOpts);
}
public addExpressionFunction<T extends readonly unknown[], R>(
name: string,
handler: ExpressionHandler<T, R>,
): void {
this.operators.expressions.set(name, handler);
}
public addBinaryOperator(operator: string, handler: BinaryOperator) {
this.operators.binary.set(operator, handler);
}
public addUnaryOperator(operator: string, handler: UnaryOperator) {
this.operators.unary.set(operator, handler);
}
public setExpressionVariable(name: string, value: unknown) {
this.vars[name] = value;
}
public getExpressionVariable(name: string): unknown {
return this.vars[name];
}
private _execAST(node: ExpressionNode, options: HookOptions): any {
return this.hooks.resolve.call(undefined, node, options);
}
private _execString(exp: string, options: HookOptions) {
if (exp === "") {
return exp;
}
const matches = exp.match(/^@\[(.*)\]@$/);
let matchedExp = exp;
if (matches) {
[, matchedExp] = Array.from(matches); // In case the expression was surrounded by @[ ]@
}
let storedAST: ExpressionNode;
try {
storedAST =
this.expressionsCache.get(matchedExp) ??
parseExpression(matchedExp, { strict: options.strict });
this.expressionsCache.set(matchedExp, storedAST);
} catch (e: any) {
if (options.throwErrors || !this.hooks.onError.call(e)) {
// Only throw the error if it's not handled by the hook, or throwErrors is true
throw new NestedError(`Error parsing expression: ${exp}`, e);
}
return;
}
try {
return this._execAST(storedAST, options);
} catch (e: any) {
if (options.throwErrors || !this.hooks.onError.call(e)) {
// Only throw the error if it's not handled by the hook, or throwErrors is true
throw new NestedError(`Error evaluating expression: ${exp}`, e);
}
}
}
private _resolveNode(
_currentValue: any,
node: ExpressionNode,
options: HookOptions,
) {
const { resolveNode, model } = options;
const expressionContext: ExpressionContext = {
...options,
evaluate: (expr) => this.evaluate(expr, options),
};
if (node.type === "Literal") {
return node.value;
}
if (node.type === "Identifier") {
return this.vars[node.name];
}
if (node.type === "Compound" || node.type === "ThisExpression") {
throw new Error(`Expression type: ${node.type} is not supported`);
}
if (node.type === "BinaryExpression" || node.type === "LogicalExpression") {
const operator = this.operators.binary.get(node.operator);
if (operator) {
if ("resolveParams" in operator) {
if (operator.resolveParams === false) {
return operator(expressionContext, node.left, node.right);
}
return operator(
expressionContext,
resolveNode(node.left),
resolveNode(node.right),
);
}
return operator(resolveNode(node.left), resolveNode(node.right));
}
return;
}
if (node.type === "UnaryExpression") {
const operator = this.operators.unary.get(node.operator);
if (operator) {
if ("resolveParams" in operator) {
return operator(
expressionContext,
operator.resolveParams === false
? node.argument
: resolveNode(node.argument),
);
}
return operator(resolveNode(node.argument));
}
return;
}
if (node.type === "Object") {
const { attributes } = node;
const resolvedAttributes: any = {};
attributes.forEach((attr) => {
const key = resolveNode(attr.key);
const value = resolveNode(attr.value);
resolvedAttributes[key] = value;
});
return resolvedAttributes;
}
if (node.type === "CallExpression") {
const expressionName = node.callTarget.name;
const operator = this.operators.expressions.get(expressionName);
if (!operator) {
throw new Error(`Unknown expression function: ${expressionName}`);
}
if ("resolveParams" in operator && operator.resolveParams === false) {
return operator(expressionContext, ...node.args);
}
const args = node.args.map((n) => resolveNode(n));
return operator(expressionContext, ...args);
}
if (node.type === "ModelRef") {
return model.get(node.ref, { context: { model: options.model } });
}
if (node.type === "MemberExpression") {
const obj = resolveNode(node.object);
const prop = resolveNode(node.property);
return obj[prop];
}
if (node.type === "Assignment") {
if (node.left.type === "ModelRef") {
const value = resolveNode(node.right);
model.set([[node.left.ref, value]]);
return value;
}
if (node.left.type === "Identifier") {
const value = resolveNode(node.right);
this.vars[node.left.name] = value;
return value;
}
return;
}
if (node.type === "ConditionalExpression") {
const result = resolveNode(node.test) ? node.consequent : node.alternate;
return resolveNode(result);
}
if (node.type === "ArrayExpression") {
return node.elements.map((ele) => resolveNode(ele));
}
if (node.type === "Modification") {
const operation = this.operators.binary.get(node.operator);
if (operation) {
let newValue;
if ("resolveParams" in operation) {
if (operation.resolveParams === false) {
newValue = operation(expressionContext, node.left, node.right);
} else {
newValue = operation(
expressionContext,
resolveNode(node.left),
resolveNode(node.right),
);
}
} else {
newValue = operation(resolveNode(node.left), resolveNode(node.right));
}
if (node.left.type === "ModelRef") {
model.set([[node.left.ref, newValue]]);
} else if (node.left.type === "Identifier") {
this.vars[node.left.name] = newValue;
}
return newValue;
}
return resolveNode(node.left);
}
}
}