@openmrs/esm-expression-evaluator
Version:
Utilities for evaluating user-defined expressions
177 lines (176 loc) • 6.63 kB
JavaScript
/** @category Utility */ import { jsep } from "./evaluator.js";
import { globalsAsync } from "./globals.js";
/**
* `extractVariableNames()` is a companion function for `evaluate()` and `evaluateAsync()` which extracts the
* names of all unbound identifiers used in the expression. The idea is to be able to extract all of the names
* of variables that will need to be supplied in order to correctly process the expression.
*
* @example
* ```ts
* // variables will be ['isEmpty', 'array']
* const variables = extractVariableNames('!isEmpty(array)')
* ```
*
* An identifier is considered "unbound" if it is not a reference to the property of an object, is not defined
* as a parameter to an inline arrow function, and is not a global value. E.g.,
*
* @example
* ```ts
* // variables will be ['obj']
* const variables = extractVariableNames('obj.prop()')
* ```
*
* @example
* ```ts
* // variables will be ['arr', 'needle']
* const variables = extractVariableNames('arr.filter(v => v === needle)')
* ```
*
* @example
* ```ts
* // variables will be ['myVar']
* const variables = extractVariableNames('new String(myVar)')
* ```
*
* Note that because this expression evaluator uses a restricted definition of "global" there are some Javascript
* globals that will be reported as a unbound expression. This is expected because the evaluator will still fail
* on these expressions.
*
* @param expression The expression to analyze, either as a string or pre-parsed expression.
* @returns An array of variable names that are unbound in the expression.
*/ export function extractVariableNames(expression) {
if (typeof expression !== 'string' && (typeof expression !== 'object' || !expression || !('type' in expression))) {
throw `Unknown expression type ${expression}. Expressions must either be a string or pre-compiled string.`;
}
const context = createAsynchronousContext();
visitExpression(typeof expression === 'string' ? jsep(expression) : expression, context);
return [
...context.variables
];
}
function visitExpression(expression, context) {
switch(expression.type){
case 'UnaryExpression':
return visitUnaryExpression(expression, context);
case 'BinaryExpression':
return visitBinaryExpression(expression, context);
case 'ConditionalExpression':
return visitConditionalExpression(expression, context);
case 'CallExpression':
return visitCallExpression(expression, context);
case 'ArrowFunctionExpression':
return visitArrowFunctionExpression(expression, context);
case 'MemberExpression':
return visitMemberExpression(expression, context);
case 'ArrayExpression':
return visitArrayExpression(expression, context);
case 'SequenceExpression':
return visitSequenceExpression(expression, context);
case 'NewExpression':
return visitNewExpression(expression, context);
case 'Literal':
return visitLiteral(expression, context);
case 'Identifier':
return visitIdentifier(expression, context);
case 'TemplateLiteral':
return visitTemplateLiteral(expression, context);
case 'TemplateElement':
return visitTemplateElement(expression, context);
default:
throw `Expression evaluator does not support expression of type '${expression.type}'`;
}
}
function visitUnaryExpression(expression, context) {
return visitExpression(expression.argument, context);
}
function visitBinaryExpression(expression, context) {
const left = visitExpression(expression.left, context);
const right = visitExpression(expression.right, context);
return [
left,
right
].filter(Boolean);
}
function visitConditionalExpression(expression, context) {
const consequent = visitExpression(expression.consequent, context);
const test = visitExpression(expression.test, context);
const alternate = visitExpression(expression.alternate, context);
return [
consequent,
test,
alternate
].filter(Boolean);
}
function visitCallExpression(expression, context) {
const fn = visitExpression(expression.callee, context);
expression.arguments?.map(handleNullableExpression(context));
return fn;
}
function visitArrowFunctionExpression(expression, context) {
const newContext = {
...context
};
newContext.isLocalExpression = true;
const params = expression.params?.map(handleNullableExpression(newContext)) ?? [];
const bodyVariables = visitExpression(expression.body, newContext) ?? [];
if (bodyVariables && Array.isArray(bodyVariables)) {
for (const v of bodyVariables){
if (!params.includes(v)) {
context.variables.add(v);
}
}
}
}
function visitMemberExpression(expression, context) {
visitExpression(expression.object, context);
const newContext = {
...context
};
newContext.isLocalExpression = true;
visitExpression(expression.property, newContext);
}
function visitArrayExpression(expression, context) {
expression.elements?.map(handleNullableExpression(context));
}
function visitSequenceExpression(expression, context) {
expression.expressions?.map(handleNullableExpression(context));
}
function visitNewExpression(expression, context) {
expression.arguments?.map(handleNullableExpression(context));
}
function visitTemplateLiteral(expression, context) {
expression.expressions?.map(handleNullableExpression(context));
expression.quasis?.map(handleNullableExpression(context));
}
function visitTemplateElement(expression, context) {}
function visitIdentifier(expression, context) {
if (!(expression.name in context.globals)) {
if (!context.isLocalExpression) {
context.variables.add(expression.name);
} else {
return expression.name;
}
}
}
function visitLiteral(expression, context) {}
function createAsynchronousContext() {
return createContextInternal(globalsAsync);
}
function createContextInternal(globals_) {
const context = {
globals: {
...globals_
},
isLocalExpression: false,
variables: new Set()
};
return context;
}
function handleNullableExpression(context) {
return function handleNullableExpressionInner(expression) {
if (expression === null) {
return null;
}
return visitExpression(expression, context);
};
}