@openmrs/esm-expression-evaluator 5.8.1-pre.2234
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +5 -0
- package/README.md +3 -0
- package/dist/openmrs-esm-expression-evaluator.js +2 -0
- package/dist/openmrs-esm-expression-evaluator.js.map +1 -0
- package/jest.config.js +9 -0
- package/package.json +56 -0
- package/src/evaluator.test.ts +194 -0
- package/src/evaluator.ts +755 -0
- package/src/index.ts +1 -0
- package/src/public.ts +13 -0
- package/tsconfig.json +27 -0
- package/webpack.config.js +42 -0
package/src/evaluator.ts
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
/** @category Utility */
|
|
2
|
+
import jsep from 'jsep';
|
|
3
|
+
import jsepArrow, { type ArrowExpression } from '@jsep-plugin/arrow';
|
|
4
|
+
import jsepNew, { type NewExpression } from '@jsep-plugin/new';
|
|
5
|
+
import jsepNumbers from '@jsep-plugin/numbers';
|
|
6
|
+
import jsepRegex from '@jsep-plugin/regex';
|
|
7
|
+
import jsepTernary from '@jsep-plugin/ternary';
|
|
8
|
+
import jsepTemplate, { type TemplateElement, type TemplateLiteral } from '@jsep-plugin/template';
|
|
9
|
+
|
|
10
|
+
jsep.plugins.register(jsepArrow);
|
|
11
|
+
jsep.plugins.register(jsepNew);
|
|
12
|
+
jsep.plugins.register(jsepNumbers);
|
|
13
|
+
jsep.plugins.register(jsepRegex);
|
|
14
|
+
jsep.plugins.register(jsepTernary);
|
|
15
|
+
jsep.plugins.register(jsepTemplate);
|
|
16
|
+
// see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_precedence
|
|
17
|
+
// for the order defined here
|
|
18
|
+
// 7 is jsep's internal for "relational operators"
|
|
19
|
+
jsep.addBinaryOp('in', 7);
|
|
20
|
+
jsep.addBinaryOp('??', 1);
|
|
21
|
+
|
|
22
|
+
/** An object containing the variable to use when evaluating this expression */
|
|
23
|
+
export type VariablesMap = {
|
|
24
|
+
[key: string]: string | number | boolean | Function | RegExp | object | null | VariablesMap | Array<VariablesMap>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The valid return types for `evaluate()` and `evaluateAsync()` */
|
|
28
|
+
export type DefaultEvaluateReturnType = string | number | boolean | Date | null | undefined;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `evaluate()` implements a relatively safe version of `eval()` that is limited to evaluating synchronous
|
|
32
|
+
* Javascript expressions. This allows us to safely add features that depend on user-supplied code without
|
|
33
|
+
* polluting the global namespace or needing to support `eval()` and the like.
|
|
34
|
+
*
|
|
35
|
+
* By default it supports any expression that evalutes to a string, number, boolean, Date, null, or undefined.
|
|
36
|
+
* Other values will result in an error.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* // shouldDisplayOptionalData will be false
|
|
41
|
+
* const shouldDisplayOptionalData = evaluate('!isEmpty(array)', {
|
|
42
|
+
* array: [],
|
|
43
|
+
* isEmpty(arr: unknown) {
|
|
44
|
+
* return Array.isArray(arr) && arr.length === 0;
|
|
45
|
+
* }
|
|
46
|
+
* })
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* Since this only implements the expression lanaguage part of Javascript, there is no support for assigning
|
|
50
|
+
* values, creating functions, or creating objects, so the following will throw an error:
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* evaluate('var a = 1; a');
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* In addition to string expressions, `evaluate()` can use an existing `jsep.Expression`, such as that returned
|
|
58
|
+
* from the `compile()` function. The goal here is to support cases where the same expression will be evaluated
|
|
59
|
+
* multiple times, possibly with different variables, e.g.,
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* const expr = compile('isEmpty(array)');
|
|
64
|
+
*
|
|
65
|
+
* // then we use it like
|
|
66
|
+
* evaluate(expr, {
|
|
67
|
+
* array: [],
|
|
68
|
+
* isEmpty(arr: unknown) {
|
|
69
|
+
* return Array.isArray(arr) && arr.length === 0;
|
|
70
|
+
* }
|
|
71
|
+
* ));
|
|
72
|
+
*
|
|
73
|
+
* evaluate(expr, {
|
|
74
|
+
* array: ['value'],
|
|
75
|
+
* isEmpty(arr: unknown) {
|
|
76
|
+
* return Array.isArray(arr) && arr.length === 0;
|
|
77
|
+
* }
|
|
78
|
+
* ));
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* This saves the overhead of parsing the expression everytime and simply allows us to evaluate it.
|
|
82
|
+
*
|
|
83
|
+
* The `variables` parameter should be used to supply any variables or functions that should be in-scope for
|
|
84
|
+
* the evaluation. A very limited number of global objects, like NaN and Infinity are always available, but
|
|
85
|
+
* any non-global values will need to be passed as a variable. Note that expressions do not have any access to
|
|
86
|
+
* the variables in the scope in which they were defined unless they are supplied here.
|
|
87
|
+
*
|
|
88
|
+
* @param expression The expression to evaluate, either as a string or pre-parsed expression
|
|
89
|
+
* @param variables Optional object which contains any variables, functions, etc. that will be available to
|
|
90
|
+
* the expression.
|
|
91
|
+
* @returns The result of evaluating the expression
|
|
92
|
+
*/
|
|
93
|
+
export function evaluate(expression: string | jsep.Expression, variables: VariablesMap = {}) {
|
|
94
|
+
return evaluateAsType(expression, variables, defaultTypePredicate);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `evaluateAsync()` implements a relatively safe version of `eval()` that can evaluate Javascript expressions
|
|
99
|
+
* that use Promises. This allows us to safely add features that depend on user-supplied code without
|
|
100
|
+
* polluting the global namespace or needing to support `eval()` and the like.
|
|
101
|
+
*
|
|
102
|
+
* By default it supports any expression that evalutes to a string, number, boolean, Date, null, or undefined.
|
|
103
|
+
* Other values will result in an error.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* // shouldDisplayOptionalData will be false
|
|
108
|
+
* const shouldDisplayOptionalData = await evaluateAsync('Promise.resolve(!isEmpty(array))', {
|
|
109
|
+
* array: [],
|
|
110
|
+
* isEmpty(arr: unknown) {
|
|
111
|
+
* return Array.isArray(arr) && arr.length === 0;
|
|
112
|
+
* }
|
|
113
|
+
* })
|
|
114
|
+
* ```
|
|
115
|
+
*
|
|
116
|
+
* Since this only implements the expression lanaguage part of Javascript, there is no support for assigning
|
|
117
|
+
* values, creating functions, or creating objects, so the following will throw an error:
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* evaluateAsync('var a = 1; a');
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* In addition to string expressions, `evaluate()` can use an existing `jsep.Expression`, such as that returned
|
|
125
|
+
* from the `compile()` function. The goal here is to support cases where the same expression will be evaluated
|
|
126
|
+
* multiple times, possibly with different variables, e.g.,
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* ```ts
|
|
130
|
+
* const expr = compile('Promise.resolve(isEmpty(array))');
|
|
131
|
+
*
|
|
132
|
+
* // then we use it like
|
|
133
|
+
* evaluateAsync(expr, {
|
|
134
|
+
* array: [],
|
|
135
|
+
* isEmpty(arr: unknown) {
|
|
136
|
+
* return Array.isArray(arr) && arr.length === 0;
|
|
137
|
+
* }
|
|
138
|
+
* ));
|
|
139
|
+
*
|
|
140
|
+
* evaluateAsync(expr, {
|
|
141
|
+
* array: ['value'],
|
|
142
|
+
* isEmpty(arr: unknown) {
|
|
143
|
+
* return Array.isArray(arr) && arr.length === 0;
|
|
144
|
+
* }
|
|
145
|
+
* ));
|
|
146
|
+
* ```
|
|
147
|
+
*
|
|
148
|
+
* This saves the overhead of parsing the expression everytime and simply allows us to evaluate it.
|
|
149
|
+
*
|
|
150
|
+
* The `variables` parameter should be used to supply any variables or functions that should be in-scope for
|
|
151
|
+
* the evaluation. A very limited number of global objects, like NaN and Infinity are always available, but
|
|
152
|
+
* any non-global values will need to be passed as a variable. Note that expressions do not have any access to
|
|
153
|
+
* the variables in the scope in which they were defined unless they are supplied here.
|
|
154
|
+
*
|
|
155
|
+
* **Note:** `evaluateAsync()` currently only supports Promise-based asynchronous flows and does not support
|
|
156
|
+
* the `await` keyword.
|
|
157
|
+
*
|
|
158
|
+
* @param expression The expression to evaluate, either as a string or pre-parsed expression
|
|
159
|
+
* @param variables Optional object which contains any variables, functions, etc. that will be available to
|
|
160
|
+
* the expression.
|
|
161
|
+
* @returns The result of evaluating the expression
|
|
162
|
+
*/
|
|
163
|
+
export async function evaluateAsync(expression: string | jsep.Expression, variables: VariablesMap = {}) {
|
|
164
|
+
return evaluateAsTypeAsync(expression, variables, defaultTypePredicate);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `evaluateAsBoolean()` is a variant of {@link evaluate()} which only supports boolean results. Useful
|
|
169
|
+
* if valid expression must return boolean values.
|
|
170
|
+
*
|
|
171
|
+
* @param expression The expression to evaluate, either as a string or pre-parsed expression
|
|
172
|
+
* @param variables Optional object which contains any variables, functions, etc. that will be available to
|
|
173
|
+
* the expression.
|
|
174
|
+
* @returns The result of evaluating the expression
|
|
175
|
+
*/
|
|
176
|
+
export function evaluateAsBoolean(expression: string | jsep.Expression, variables: VariablesMap = {}) {
|
|
177
|
+
return evaluateAsType(expression, variables, booleanTypePredicate);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* `evaluateAsBooleanAsync()` is a variant of {@link evaluateAsync()} which only supports boolean results. Useful
|
|
182
|
+
* if valid expression must return boolean values.
|
|
183
|
+
*
|
|
184
|
+
* @param expression The expression to evaluate, either as a string or pre-parsed expression
|
|
185
|
+
* @param variables Optional object which contains any variables, functions, etc. that will be available to
|
|
186
|
+
* the expression.
|
|
187
|
+
* @returns The result of evaluating the expression
|
|
188
|
+
*/
|
|
189
|
+
export function evaluateAsBooleanAsync(expression: string | jsep.Expression, variables: VariablesMap = {}) {
|
|
190
|
+
return evaluateAsTypeAsync(expression, variables, booleanTypePredicate);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `evaluateAsNumber()` is a variant of {@link evaluate()} which only supports number results. Useful
|
|
195
|
+
* if valid expression must return numeric values.
|
|
196
|
+
*
|
|
197
|
+
* @param expression The expression to evaluate, either as a string or pre-parsed expression
|
|
198
|
+
* @param variables Optional object which contains any variables, functions, etc. that will be available to
|
|
199
|
+
* the expression.
|
|
200
|
+
* @returns The result of evaluating the expression
|
|
201
|
+
*/
|
|
202
|
+
export function evaluateAsNumber(expression: string | jsep.Expression, variables: VariablesMap = {}) {
|
|
203
|
+
return evaluateAsType(expression, variables, numberTypePredicate);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `evaluateAsNumberAsync()` is a variant of {@link evaluateAsync()} which only supports number results. Useful
|
|
208
|
+
* if valid expression must return numeric values.
|
|
209
|
+
*
|
|
210
|
+
* @param expression The expression to evaluate, either as a string or pre-parsed expression
|
|
211
|
+
* @param variables Optional object which contains any variables, functions, etc. that will be available to
|
|
212
|
+
* the expression.
|
|
213
|
+
* @returns The result of evaluating the expression
|
|
214
|
+
*/
|
|
215
|
+
export function evaluateAsNumberAsync(expression: string | jsep.Expression, variables: VariablesMap = {}) {
|
|
216
|
+
return evaluateAsType(expression, variables, numberTypePredicate);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* `evaluateAsType()` is a type-safe version of {@link evaluate()} which returns a result if the result
|
|
221
|
+
* passes a custom type predicate. The main use-case for this is to narrow the return types allowed based on
|
|
222
|
+
* context, e.g., if the expected result should be a number or boolean, you can supply a custom type-guard
|
|
223
|
+
* to ensure that only number or boolean results are returned.
|
|
224
|
+
*
|
|
225
|
+
* @param expression The expression to evaluate, either as a string or pre-parsed expression
|
|
226
|
+
* @param variables Optional object which contains any variables, functions, etc. that will be available to
|
|
227
|
+
* the expression.
|
|
228
|
+
* @param typePredicate A [type predicate](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
|
|
229
|
+
* which asserts that the result value matches one of the expected results.
|
|
230
|
+
* @returns The result of evaluating the expression
|
|
231
|
+
*/
|
|
232
|
+
export function evaluateAsType<T>(
|
|
233
|
+
expression: string | jsep.Expression,
|
|
234
|
+
variables: VariablesMap = {},
|
|
235
|
+
typePredicate: (result: unknown) => result is T,
|
|
236
|
+
): T {
|
|
237
|
+
if (typeof expression !== 'string' && (typeof expression !== 'object' || !expression || !('type' in expression))) {
|
|
238
|
+
throw `Unknown expression type ${expression}. Expressions must either be a string or pre-compiled string.`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (typeof variables === 'undefined' || variables === null) {
|
|
242
|
+
variables = {};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const context = createSynchronousContext(variables);
|
|
246
|
+
const result = visitExpression(typeof expression === 'string' ? jsep(expression) : expression, context);
|
|
247
|
+
if (typePredicate(result)) {
|
|
248
|
+
return result;
|
|
249
|
+
} else {
|
|
250
|
+
throw {
|
|
251
|
+
type: 'Invalid result',
|
|
252
|
+
message:
|
|
253
|
+
typeof expression === 'string'
|
|
254
|
+
? `The expression ${expression} did not produce a valid result`
|
|
255
|
+
: 'The expression did not produce a valid result',
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* `evaluateAsTypeAsync()` is a type-safe version of {@link evaluateAsync()} which returns a result if the result
|
|
262
|
+
* passes a custom type predicate. The main use-case for this is to narrow the return types allowed based on
|
|
263
|
+
* context, e.g., if the expected result should be a number or boolean, you can supply a custom type-guard
|
|
264
|
+
* to ensure that only number or boolean results are returned.
|
|
265
|
+
*
|
|
266
|
+
* @param expression The expression to evaluate, either as a string or pre-parsed expression
|
|
267
|
+
* @param variables Optional object which contains any variables, functions, etc. that will be available to
|
|
268
|
+
* the expression.
|
|
269
|
+
* @param typePredicate A [type predicate](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
|
|
270
|
+
* which asserts that the result value matches one of the expected results.
|
|
271
|
+
* @returns The result of evaluating the expression
|
|
272
|
+
*/
|
|
273
|
+
export async function evaluateAsTypeAsync<T>(
|
|
274
|
+
expression: string | jsep.Expression,
|
|
275
|
+
variables: VariablesMap = {},
|
|
276
|
+
typePredicate: (result: unknown) => result is T,
|
|
277
|
+
): Promise<T> {
|
|
278
|
+
if (typeof expression !== 'string' && (typeof expression !== 'object' || !expression || !('type' in expression))) {
|
|
279
|
+
return Promise.reject(
|
|
280
|
+
`Unknown expression type ${expression}. Expressions must either be a string or pre-compiled string.`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (typeof variables === 'undefined' || variables === null) {
|
|
285
|
+
variables = {};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const context = createAsynchronousContext(variables);
|
|
289
|
+
return Promise.resolve(visitExpression(typeof expression === 'string' ? jsep(expression) : expression, context)).then(
|
|
290
|
+
(result) => {
|
|
291
|
+
if (typePredicate(result)) {
|
|
292
|
+
return result;
|
|
293
|
+
} else {
|
|
294
|
+
throw {
|
|
295
|
+
type: 'Invalid result',
|
|
296
|
+
message:
|
|
297
|
+
typeof expression === 'string'
|
|
298
|
+
? `The expression ${expression} did not produce a valid result`
|
|
299
|
+
: 'The expression did not produce a valid result',
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* `compile()` is a companion function for use with {@link evaluate()} and the various `evaluateAs*()` functions.
|
|
308
|
+
* It processes an expression string into the resulting AST that is executed by those functions. This is useful if
|
|
309
|
+
* you have an expression that will need to be evaluated mulitple times, potentially with different values, as the
|
|
310
|
+
* lexing and parsing steps can be skipped by using the AST object returned from this.
|
|
311
|
+
*
|
|
312
|
+
* The returned AST is intended to be opaque to client applications, but, of course, it is possible to manipulate
|
|
313
|
+
* the AST before passing it back to {@link evaluate()}, if desired. This might be useful if, for example, certain
|
|
314
|
+
* values are known to be constant.
|
|
315
|
+
*
|
|
316
|
+
* @param expression The expression to be parsed
|
|
317
|
+
* @returns An executable AST representation of the expression
|
|
318
|
+
*/
|
|
319
|
+
export function compile(expression: string): jsep.Expression {
|
|
320
|
+
return jsep(expression);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Pre-defined type guards
|
|
324
|
+
function defaultTypePredicate(result: unknown): result is DefaultEvaluateReturnType {
|
|
325
|
+
return (
|
|
326
|
+
typeof result === 'string' ||
|
|
327
|
+
typeof result === 'number' ||
|
|
328
|
+
typeof result === 'boolean' ||
|
|
329
|
+
typeof result === 'undefined' ||
|
|
330
|
+
result === null ||
|
|
331
|
+
result instanceof Date
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function booleanTypePredicate(result: unknown): result is Boolean {
|
|
336
|
+
return typeof result === 'boolean';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function numberTypePredicate(result: unknown): result is number {
|
|
340
|
+
return typeof result === 'number';
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Implementation
|
|
344
|
+
|
|
345
|
+
// This is the core of the implementation; it takes an expression, the variables and the current object
|
|
346
|
+
// each expression is dispatched to an appropriate handler.
|
|
347
|
+
function visitExpression(expression: jsep.Expression, context: EvaluationContext) {
|
|
348
|
+
switch (expression.type) {
|
|
349
|
+
case 'UnaryExpression':
|
|
350
|
+
return visitUnaryExpression(expression as jsep.UnaryExpression, context);
|
|
351
|
+
case 'BinaryExpression':
|
|
352
|
+
return visitBinaryExpression(expression as jsep.BinaryExpression, context);
|
|
353
|
+
case 'ConditionalExpression':
|
|
354
|
+
return visitConditionalExpression(expression as jsep.ConditionalExpression, context);
|
|
355
|
+
case 'CallExpression':
|
|
356
|
+
return visitCallExpression(expression as jsep.CallExpression, context);
|
|
357
|
+
case 'ArrowFunctionExpression':
|
|
358
|
+
return visitArrowFunctionExpression(expression as ArrowExpression, context);
|
|
359
|
+
case 'MemberExpression':
|
|
360
|
+
return visitMemberExpression(expression as jsep.MemberExpression, context);
|
|
361
|
+
case 'ArrayExpression':
|
|
362
|
+
return visitArrayExpression(expression as jsep.ArrayExpression, context);
|
|
363
|
+
case 'SequenceExpression':
|
|
364
|
+
return visitSequenceExpression(expression as jsep.SequenceExpression, context);
|
|
365
|
+
case 'NewExpression':
|
|
366
|
+
return visitNewExpression(expression as NewExpression, context);
|
|
367
|
+
case 'Literal':
|
|
368
|
+
return visitLiteral(expression as jsep.Literal, context);
|
|
369
|
+
case 'Identifier':
|
|
370
|
+
return visitIdentifier(expression as jsep.Identifier, context);
|
|
371
|
+
case 'TemplateLiteral':
|
|
372
|
+
return visitTemplateLiteral(expression as TemplateLiteral, context);
|
|
373
|
+
case 'TemplateElement':
|
|
374
|
+
return visitTemplateElement(expression as TemplateElement, context);
|
|
375
|
+
default:
|
|
376
|
+
throw `Expression evaluator does not support expression of type '${expression.type}'`;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function visitExpressionName(expression: jsep.Expression, context: EvaluationContext) {
|
|
381
|
+
switch (expression.type) {
|
|
382
|
+
case 'Literal':
|
|
383
|
+
return (expression as jsep.Literal).value as string;
|
|
384
|
+
case 'Identifier':
|
|
385
|
+
return (expression as jsep.Identifier).name;
|
|
386
|
+
case 'MemberExpression':
|
|
387
|
+
return visitExpressionName((expression as jsep.MemberExpression).property, context);
|
|
388
|
+
default:
|
|
389
|
+
throw `VisitExpressionName does not support expression of type '${expression.type}'`;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function visitUnaryExpression(expression: jsep.UnaryExpression, context: EvaluationContext) {
|
|
394
|
+
const value = visitExpression(expression.argument, context);
|
|
395
|
+
|
|
396
|
+
switch (expression.operator) {
|
|
397
|
+
case '+':
|
|
398
|
+
return +value;
|
|
399
|
+
case '-':
|
|
400
|
+
return -value;
|
|
401
|
+
case '~':
|
|
402
|
+
return ~value;
|
|
403
|
+
case '!':
|
|
404
|
+
return !value;
|
|
405
|
+
default:
|
|
406
|
+
throw `Expression evaluator does not support operator '${expression.operator}''`;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function visitBinaryExpression(expression: jsep.BinaryExpression, context: EvaluationContext) {
|
|
411
|
+
let left = visitExpression(expression.left, context);
|
|
412
|
+
let right = visitExpression(expression.right, context);
|
|
413
|
+
|
|
414
|
+
switch (expression.operator) {
|
|
415
|
+
case '+':
|
|
416
|
+
return left + right;
|
|
417
|
+
case '-':
|
|
418
|
+
return left - right;
|
|
419
|
+
case '*':
|
|
420
|
+
return left * right;
|
|
421
|
+
case '/':
|
|
422
|
+
return left / right;
|
|
423
|
+
case '%':
|
|
424
|
+
return left % right;
|
|
425
|
+
case '**':
|
|
426
|
+
return left ** right;
|
|
427
|
+
case '==':
|
|
428
|
+
return left == right;
|
|
429
|
+
case '===':
|
|
430
|
+
return left === right;
|
|
431
|
+
case '!=':
|
|
432
|
+
return left != right;
|
|
433
|
+
case '!==':
|
|
434
|
+
return left !== right;
|
|
435
|
+
case '>':
|
|
436
|
+
return left > right;
|
|
437
|
+
case '>=':
|
|
438
|
+
return left >= right;
|
|
439
|
+
case '<':
|
|
440
|
+
return left < right;
|
|
441
|
+
case '<=':
|
|
442
|
+
return left <= right;
|
|
443
|
+
case 'in':
|
|
444
|
+
return left in right;
|
|
445
|
+
case '&&':
|
|
446
|
+
return left && right;
|
|
447
|
+
case '||':
|
|
448
|
+
return left || right;
|
|
449
|
+
case '??':
|
|
450
|
+
return left ?? right;
|
|
451
|
+
default:
|
|
452
|
+
throw `Expression evaluator does not support operator '${expression.operator}' operator`;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function visitConditionalExpression(expression: jsep.ConditionalExpression, context: EvaluationContext) {
|
|
457
|
+
const test = visitExpression(expression.test, context);
|
|
458
|
+
return test ? visitExpression(expression.consequent, context) : visitExpression(expression.alternate, context);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function visitCallExpression(expression: jsep.CallExpression, context: EvaluationContext) {
|
|
462
|
+
let args = expression.arguments?.map(handleNullableExpression(context));
|
|
463
|
+
let callee = visitExpression(expression.callee, context);
|
|
464
|
+
|
|
465
|
+
if (!callee) {
|
|
466
|
+
throw `No function named ${expression.callee} is defined in this context`;
|
|
467
|
+
} else if (!(callee instanceof Function)) {
|
|
468
|
+
throw `${expression.callee} is not a function`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return callee(...args);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function visitArrowFunctionExpression(expression: ArrowExpression, context: EvaluationContext) {
|
|
475
|
+
const params =
|
|
476
|
+
expression.params?.map((p) => {
|
|
477
|
+
switch (p.type) {
|
|
478
|
+
case 'Identifier':
|
|
479
|
+
return (p as jsep.Identifier).name;
|
|
480
|
+
default:
|
|
481
|
+
throw `Cannot handle parameter of type ${p.type}`;
|
|
482
|
+
}
|
|
483
|
+
}) ?? [];
|
|
484
|
+
|
|
485
|
+
return function (...rest: unknown[]) {
|
|
486
|
+
if (rest.length < params.length) {
|
|
487
|
+
throw `Required argument(s) ${params.slice(rest.length, -1).join(', ')} were not provided`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const vars = Object.fromEntries(
|
|
491
|
+
params.reduce((acc: Array<[string, VariablesMap['a']]>, p, idx) => {
|
|
492
|
+
const val = rest[idx];
|
|
493
|
+
if (isValidVariableType(val)) {
|
|
494
|
+
acc.push([p, val]);
|
|
495
|
+
}
|
|
496
|
+
return acc;
|
|
497
|
+
}, []),
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
return visitExpression(expression.body, context.addVariables(vars));
|
|
501
|
+
}.bind(context.thisObj ?? null);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function visitMemberExpression(expression: jsep.MemberExpression, context: EvaluationContext) {
|
|
505
|
+
let obj = visitExpression(expression.object, context);
|
|
506
|
+
|
|
507
|
+
if (obj === undefined) {
|
|
508
|
+
switch (expression.object.type) {
|
|
509
|
+
case 'Identifier': {
|
|
510
|
+
let objectName = visitExpressionName(expression.object, context);
|
|
511
|
+
throw ReferenceError(`ReferenceError: ${objectName} is not defined`);
|
|
512
|
+
}
|
|
513
|
+
case 'MemberExpression': {
|
|
514
|
+
let propertyName = visitExpressionName(expression.property, context);
|
|
515
|
+
throw TypeError(`TypeError: cannot read properties of undefined (reading '${propertyName}')`);
|
|
516
|
+
}
|
|
517
|
+
default:
|
|
518
|
+
throw `VisitMemberExpression does not support operator '${expression.object.type}' type`;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
let newObj = obj;
|
|
523
|
+
if (typeof obj === 'string') {
|
|
524
|
+
newObj = String.prototype;
|
|
525
|
+
} else if (typeof obj === 'number') {
|
|
526
|
+
newObj = Number.prototype;
|
|
527
|
+
} else if (typeof obj === 'function') {
|
|
528
|
+
// no-op
|
|
529
|
+
} else if (typeof obj !== 'object') {
|
|
530
|
+
throw `VisitMemberExpression does not support member access on type ${typeof obj}`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
context.thisObj = newObj;
|
|
534
|
+
|
|
535
|
+
let result: unknown;
|
|
536
|
+
switch (expression.property.type) {
|
|
537
|
+
case 'Identifier':
|
|
538
|
+
case 'MemberExpression':
|
|
539
|
+
result = visitExpression(expression.property, context);
|
|
540
|
+
break;
|
|
541
|
+
default: {
|
|
542
|
+
const property = visitExpression(expression.property, context);
|
|
543
|
+
if (typeof property === 'undefined') {
|
|
544
|
+
throw { type: 'Illegal property access', message: 'No property was supplied to the property access' };
|
|
545
|
+
}
|
|
546
|
+
validatePropertyName(property);
|
|
547
|
+
result = obj[property];
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (typeof result === 'function') {
|
|
552
|
+
return result.bind(obj);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function visitArrayExpression(expression: jsep.ArrayExpression, context: EvaluationContext) {
|
|
559
|
+
return expression.elements?.map(handleNullableExpression(context));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function visitSequenceExpression(expression: jsep.SequenceExpression, context: EvaluationContext) {
|
|
563
|
+
const result = expression.expressions.map(handleNullableExpression(context));
|
|
564
|
+
return result[result.length - 1];
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function visitNewExpression(expression: NewExpression, context: EvaluationContext) {
|
|
568
|
+
if (expression.callee && expression.callee.type === 'Identifier') {
|
|
569
|
+
let args = expression.arguments?.map(handleNullableExpression(context)) as Array<any>;
|
|
570
|
+
switch (expression.callee.name) {
|
|
571
|
+
case 'Date': {
|
|
572
|
+
/** @ts-ignore because we can use the spread operator here */
|
|
573
|
+
return new Date(...args);
|
|
574
|
+
}
|
|
575
|
+
case 'RegExp':
|
|
576
|
+
/** @ts-ignore because we can use the spread operator here */
|
|
577
|
+
return new RegExp(...args);
|
|
578
|
+
default:
|
|
579
|
+
throw `Cannot instantiate object of type ${expression.callee.name}`;
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
582
|
+
if (!expression.callee) {
|
|
583
|
+
throw `Could not handle "new" without a specified class`;
|
|
584
|
+
} else {
|
|
585
|
+
throw 'new must be called with either Date or RegExp';
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function visitTemplateLiteral(expression: TemplateLiteral, context: EvaluationContext) {
|
|
591
|
+
const expressions: Array<unknown> = expression.expressions?.map(handleNullableExpression(context)) ?? [];
|
|
592
|
+
const quasis: Array<{ tail: boolean; value: unknown }> =
|
|
593
|
+
expression.quasis?.map(handleNullableExpression(context)) ?? [];
|
|
594
|
+
return (
|
|
595
|
+
quasis
|
|
596
|
+
.filter((q) => !q.tail)
|
|
597
|
+
.map((q) => q.value)
|
|
598
|
+
.join('') +
|
|
599
|
+
expressions.join('') +
|
|
600
|
+
quasis
|
|
601
|
+
.filter((q) => q.tail)
|
|
602
|
+
.map((q) => q.value)
|
|
603
|
+
.join('')
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function visitTemplateElement(expression: TemplateElement, context: EvaluationContext) {
|
|
608
|
+
return { value: expression.cooked, tail: expression.tail };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function visitIdentifier(expression: jsep.Identifier, context: EvaluationContext) {
|
|
612
|
+
validatePropertyName(expression.name);
|
|
613
|
+
|
|
614
|
+
// we support both `object` and `function` in the same way as technically property access on functions
|
|
615
|
+
// is possible; the use-case here is to support JS's "static" functions like `Number.isInteger()`, which
|
|
616
|
+
// is technically reading a property on a function
|
|
617
|
+
const thisObj = context.thisObj;
|
|
618
|
+
if (thisObj && (typeof thisObj === 'object' || typeof thisObj === 'function') && expression.name in thisObj) {
|
|
619
|
+
const result = thisObj[expression.name];
|
|
620
|
+
validatePropertyName(result);
|
|
621
|
+
return result;
|
|
622
|
+
} else if (context.variables && expression.name in context.variables) {
|
|
623
|
+
const result = context.variables[expression.name];
|
|
624
|
+
validatePropertyName(result);
|
|
625
|
+
return result;
|
|
626
|
+
} else if (expression.name in context.globals) {
|
|
627
|
+
return context.globals[expression.name];
|
|
628
|
+
} else {
|
|
629
|
+
return undefined;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function visitLiteral(expression: jsep.Literal, context: EvaluationContext) {
|
|
634
|
+
validatePropertyName(expression.value);
|
|
635
|
+
return expression.value;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Internal helpers and utilities
|
|
639
|
+
|
|
640
|
+
interface EvaluationContext {
|
|
641
|
+
thisObj: object | undefined;
|
|
642
|
+
variables: VariablesMap;
|
|
643
|
+
globals: typeof globals | typeof globalsAsync;
|
|
644
|
+
addVariables(vars: VariablesMap): EvaluationContext;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function createSynchronousContext(variables: VariablesMap): EvaluationContext {
|
|
648
|
+
return createContextInternal(variables, globals);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function createAsynchronousContext(variables: VariablesMap): EvaluationContext {
|
|
652
|
+
return createContextInternal(variables, globalsAsync);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function createContextInternal(variables: VariablesMap, globals_: typeof globals | typeof globalsAsync) {
|
|
656
|
+
const context = {
|
|
657
|
+
thisObj: undefined,
|
|
658
|
+
variables: { ...variables },
|
|
659
|
+
globals: { ...globals_ },
|
|
660
|
+
addVariables(vars: VariablesMap) {
|
|
661
|
+
this.variables = { ...this.variables, ...vars };
|
|
662
|
+
return this;
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
context.addVariables.bind(context);
|
|
667
|
+
|
|
668
|
+
return context;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// helper useful for handling arrays of expressions, since `null` expressions should not be
|
|
672
|
+
// dispatched to `visitExpression()`
|
|
673
|
+
function handleNullableExpression(context: EvaluationContext) {
|
|
674
|
+
return function handleNullableExpressionInner(expression: jsep.Expression | null) {
|
|
675
|
+
if (expression === null) {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return visitExpression(expression, context);
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function validatePropertyName(name: unknown) {
|
|
684
|
+
if (name === '__proto__' || name === 'prototype' || name === 'constructor') {
|
|
685
|
+
throw { type: 'Illegal property access', message: `Cannot access the ${name} property of objects` };
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function isValidVariableType(val: unknown): val is VariablesMap['a'] {
|
|
690
|
+
if (
|
|
691
|
+
typeof val === 'string' ||
|
|
692
|
+
typeof val === 'number' ||
|
|
693
|
+
typeof val === 'boolean' ||
|
|
694
|
+
typeof val === 'function' ||
|
|
695
|
+
val === null ||
|
|
696
|
+
val instanceof RegExp
|
|
697
|
+
) {
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (typeof val === 'object') {
|
|
702
|
+
for (const key of Object.keys(val)) {
|
|
703
|
+
if (!isValidVariableType(val[key])) {
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (Array.isArray(val)) {
|
|
712
|
+
for (const item of val) {
|
|
713
|
+
if (!isValidVariableType(item)) {
|
|
714
|
+
return false;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const globals = {
|
|
723
|
+
Array,
|
|
724
|
+
Boolean,
|
|
725
|
+
Symbol,
|
|
726
|
+
Infinity,
|
|
727
|
+
NaN,
|
|
728
|
+
Math,
|
|
729
|
+
Number,
|
|
730
|
+
BigInt,
|
|
731
|
+
String,
|
|
732
|
+
RegExp,
|
|
733
|
+
JSON,
|
|
734
|
+
isFinite,
|
|
735
|
+
isNaN,
|
|
736
|
+
parseFloat,
|
|
737
|
+
parseInt,
|
|
738
|
+
decodeURI,
|
|
739
|
+
encodeURI,
|
|
740
|
+
encodeURIComponent,
|
|
741
|
+
Object: {
|
|
742
|
+
__proto__: undefined,
|
|
743
|
+
assign: Object.assign.bind(null),
|
|
744
|
+
fromEntries: Object.fromEntries.bind(null),
|
|
745
|
+
hasOwn: Object.hasOwn.bind(null),
|
|
746
|
+
keys: Object.keys.bind(null),
|
|
747
|
+
is: Object.is.bind(null),
|
|
748
|
+
values: Object.values.bind(null),
|
|
749
|
+
},
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
const globalsAsync = {
|
|
753
|
+
...globals,
|
|
754
|
+
Promise,
|
|
755
|
+
};
|