1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { ExpressionKind } from 'ast-types/lib/gen/kinds';
import type { Config as EsprimaConfig } from 'esprima-next';
import { parse as esprimaParse } from 'esprima-next';
import { DateTime } from 'luxon';
import { parse, visit, types, print } from 'recast';
import { getOption } from 'recast/lib/util';
import { arrayExtensions } from './array-extensions';
import { booleanExtensions } from './boolean-extensions';
import { dateExtensions } from './date-extensions';
import { joinExpression, splitExpression } from './expression-parser';
import type { ExpressionChunk, ExpressionCode } from './expression-parser';
import type { ExtensionMap } from './extensions';
import { numberExtensions } from './number-extensions';
import { objectExtensions } from './object-extensions';
import { stringExtensions } from './string-extensions';
import { checkIfValueDefinedOrThrow } from './utils';
import { ExpressionExtensionError } from '../errors/expression-extension.error';
const EXPRESSION_EXTENDER = 'extend';
const EXPRESSION_EXTENDER_OPTIONAL = 'extendOptional';
function isEmpty(value: unknown) {
return value === null || value === undefined || !value;
}
function isNotEmpty(value: unknown) {
return !isEmpty(value);
}
export const EXTENSION_OBJECTS: ExtensionMap[] = [
arrayExtensions,
dateExtensions,
numberExtensions,
objectExtensions,
stringExtensions,
booleanExtensions,
];
// eslint-disable-next-line @typescript-eslint/no-restricted-types
const genericExtensions: Record<string, Function> = {
isEmpty,
isNotEmpty,
};
const EXPRESSION_EXTENSION_METHODS = Array.from(
new Set([
...Object.keys(stringExtensions.functions),
...Object.keys(numberExtensions.functions),
...Object.keys(dateExtensions.functions),
...Object.keys(arrayExtensions.functions),
...Object.keys(objectExtensions.functions),
...Object.keys(booleanExtensions.functions),
...Object.keys(genericExtensions),
]),
);
const EXPRESSION_EXTENSION_REGEX = new RegExp(
`(\\$if|\\.(${EXPRESSION_EXTENSION_METHODS.join('|')})\\s*(\\?\\.)?)\\s*\\(`,
);
const isExpressionExtension = (str: string) => EXPRESSION_EXTENSION_METHODS.some((m) => m === str);
export const hasExpressionExtension = (str: string): boolean =>
EXPRESSION_EXTENSION_REGEX.test(str);
export const hasNativeMethod = (method: string): boolean => {
if (hasExpressionExtension(method)) {
return false;
}
const methods = method
.replace(/[^\w\s]/gi, ' ')
.split(' ')
.filter(Boolean); // DateTime.now().toLocaleString().format() => [DateTime,now,toLocaleString,format]
return methods.every((methodName) => {
return [String.prototype, Array.prototype, Number.prototype, Date.prototype].some(
(nativeType) => {
if (methodName in nativeType) {
return true;
}
return false;
},
);
});
};
// /**
// * recast's types aren't great and we need to use a lot of anys
// */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function parseWithEsprimaNext(source: string, options?: any): any {
const ast = esprimaParse(source, {
loc: true,
locations: true,
comment: true,
range: getOption(options, 'range', false) as boolean,
tolerant: getOption(options, 'tolerant', true) as boolean,
tokens: true,
jsx: getOption(options, 'jsx', false) as boolean,
sourceType: getOption(options, 'sourceType', 'module') as string,
} as EsprimaConfig);
return ast;
}
/**
* A function to inject an extender function call into the AST of an expression.
* This uses recast to do the transform.
*
* This function also polyfills optional chaining if using extended functions.
*
* ```ts
* 'a'.method('x') // becomes
* extend('a', 'method', ['x']);
*
* 'a'.first('x').second('y') // becomes
* extend(extend('a', 'first', ['x']), 'second', ['y']));
* ```
*/
export const extendTransform = (expression: string): { code: string } | undefined => {
try {
const ast = parse(expression, { parser: { parse: parseWithEsprimaNext } }) as types.ASTNode;
let currentChain = 1;
// Polyfill optional chaining
visit(ast, {
// eslint-disable-next-line complexity
visitChainExpression(path) {
this.traverse(path);
const chainNumber = currentChain;
currentChain += 1;
// This is to match behavior in our original expression evaluator (tmpl)
const globalIdentifier = types.builders.identifier(
typeof window !== 'object' ? 'global' : 'window',
);
// We want to define all of our commonly used identifiers and member
// expressions now so we don't have to create multiple instances
const undefinedIdentifier = types.builders.identifier('undefined');
const cancelIdentifier = types.builders.identifier(`chainCancelToken${chainNumber}`);
const valueIdentifier = types.builders.identifier(`chainValue${chainNumber}`);
const cancelMemberExpression = types.builders.memberExpression(
globalIdentifier,
cancelIdentifier,
);
const valueMemberExpression = types.builders.memberExpression(
globalIdentifier,
valueIdentifier,
);
const patchedStack: ExpressionKind[] = [];
// This builds the cancel check. This lets us slide to the end of the expression
// if it's undefined/null at any of the optional points of the chain.
const buildCancelCheckWrapper = (node: ExpressionKind): ExpressionKind => {
return types.builders.conditionalExpression(
types.builders.binaryExpression(
'===',
cancelMemberExpression,
types.builders.booleanLiteral(true),
),
undefinedIdentifier,
node,
);
};
// This is just a quick small wrapper to create the assignment expression
// for the running value.
const buildValueAssignWrapper = (node: ExpressionKind): ExpressionKind => {
return types.builders.assignmentExpression('=', valueMemberExpression, node);
};
// This builds what actually does the comparison. It wraps the current
// chunk of the expression with a nullish coalescing operator that returns
// undefined if it's null or undefined. We do this because optional chains
// always return undefined if they fail part way, even if the value they
// fail on is null.
const buildOptionalWrapper = (node: ExpressionKind): ExpressionKind => {
return types.builders.binaryExpression(
'===',
types.builders.logicalExpression(
'??',
buildValueAssignWrapper(node),
undefinedIdentifier,
),
undefinedIdentifier,
);
};
// Another small wrapper, but for assigning to the cancel token this time.
const buildCancelAssignWrapper = (node: ExpressionKind): ExpressionKind => {
return types.builders.assignmentExpression('=', cancelMemberExpression, node);
};
let currentNode: ExpressionKind = path.node.expression;
let currentPatch: ExpressionKind | null = null;
let patchTop: ExpressionKind | null = null;
let wrapNextTopInOptionalExtend = false;
// This patches the previous node to use our current one as it's left hand value.
// It takes `window.chainValue1.test1` and `window.chainValue1.test2` and turns it
// into `window.chainValue1.test2.test1`.
const updatePatch = (toPatch: ExpressionKind, node: ExpressionKind) => {
if (toPatch.type === 'MemberExpression' || toPatch.type === 'OptionalMemberExpression') {
toPatch.object = node;
} else if (
toPatch.type === 'CallExpression' ||
toPatch.type === 'OptionalCallExpression'
) {
toPatch.callee = node;
}
};
// This loop walks down an optional chain from the top. This will walk
// from right to left through an optional chain. We keep track of our current
// top of the chain (furthest right) and create a chain below it. This chain
// contains all of the (member and call) expressions that we need. These are
// patched versions that reference our current chain value. We then push this
// chain onto a stack when we hit an optional point in our chain.
while (true) {
// This should only ever be these types but you can optional chain on
// JSX nodes, which we don't support.
if (
currentNode.type === 'MemberExpression' ||
currentNode.type === 'OptionalMemberExpression' ||
currentNode.type === 'CallExpression' ||
currentNode.type === 'OptionalCallExpression'
) {
let patchNode: ExpressionKind;
// Here we take the current node and extract the parts we actually care
// about.
// In the case of a member expression we take the property it's trying to
// access and make the object it's accessing be our chain value.
if (
currentNode.type === 'MemberExpression' ||
currentNode.type === 'OptionalMemberExpression'
) {
patchNode = types.builders.memberExpression(
valueMemberExpression,
currentNode.property,
);
// In the case of a call expression we take the arguments and make the
// callee our chain value.
} else {
patchNode = types.builders.callExpression(
valueMemberExpression,
currentNode.arguments,
);
}
// If we have a previous node we patch it here.
if (currentPatch) {
updatePatch(currentPatch, patchNode);
}
// If we have no top patch (first run, or just pushed onto the stack) we
// note it here.
if (!patchTop) {
patchTop = patchNode;
}
currentPatch = patchNode;
// This is an optional in our chain. In here we'll push the node onto the
// stack. We also do a polyfill if the top of the stack is function call
// that might be a extended function.
if (currentNode.optional) {
// Implement polyfill described below
if (wrapNextTopInOptionalExtend) {
wrapNextTopInOptionalExtend = false;
// This shouldn't ever happen
if (
patchTop.type === 'MemberExpression' &&
patchTop.property.type === 'Identifier'
) {
patchTop = types.builders.callExpression(
types.builders.identifier(EXPRESSION_EXTENDER_OPTIONAL),
[patchTop.object, types.builders.stringLiteral(patchTop.property.name)],
);
}
}
patchedStack.push(patchTop);
patchTop = null;
currentPatch = null;
// Attempting to optional chain on an extended function. If we don't
// polyfill this most calls will always be undefined. Marking that the
// next part of the chain should be wrapped in our polyfill.
if (
(currentNode.type === 'CallExpression' ||
currentNode.type === 'OptionalCallExpression') &&
(currentNode.callee.type === 'MemberExpression' ||
currentNode.callee.type === 'OptionalMemberExpression') &&
currentNode.callee.property.type === 'Identifier' &&
isExpressionExtension(currentNode.callee.property.name)
) {
wrapNextTopInOptionalExtend = true;
}
}
// Finally we get the next point AST to walk down.
if (
currentNode.type === 'MemberExpression' ||
currentNode.type === 'OptionalMemberExpression'
) {
currentNode = currentNode.object;
} else {
currentNode = currentNode.callee;
}
} else {
// We update the final patch to point to the first part of the optional chain
// which is probably an identifier for an object.
if (currentPatch) {
updatePatch(currentPatch, currentNode);
if (!patchTop) {
patchTop = currentPatch;
}
}
if (wrapNextTopInOptionalExtend) {
wrapNextTopInOptionalExtend = false;
// This shouldn't ever happen
if (
patchTop?.type === 'MemberExpression' &&
patchTop.property.type === 'Identifier'
) {
patchTop = types.builders.callExpression(
types.builders.identifier(EXPRESSION_EXTENDER_OPTIONAL),
[patchTop.object, types.builders.stringLiteral(patchTop.property.name)],
);
}
}
// Push the first part of our chain to stack.
if (patchTop) {
patchedStack.push(patchTop);
} else {
patchedStack.push(currentNode);
}
break;
}
}
// Since we're working from right to left we need to flip the stack
// for the correct order of operations
patchedStack.reverse();
// Walk the node stack and wrap all our expressions in cancel/assignment
// wrappers.
for (let i = 0; i < patchedStack.length; i++) {
let node = patchedStack[i];
// We don't wrap the last expression in an assignment wrapper because
// it's going to be returned anyway. We just wrap it in a cancel check
// wrapper.
if (i !== patchedStack.length - 1) {
node = buildCancelAssignWrapper(buildOptionalWrapper(node));
}
// Don't wrap the first part in a cancel wrapper because the cancel
// token will always be undefined.
if (i !== 0) {
node = buildCancelCheckWrapper(node);
}
// Replace the node in the stack with our wrapped one
patchedStack[i] = node;
}
// Put all our expressions in a sequence expression (also called a
// group operator). These will all be executed in order and the value
// of the final expression will be returned.
const sequenceNode = types.builders.sequenceExpression(patchedStack);
path.replace(sequenceNode);
},
});
// Extended functions
visit(ast, {
visitCallExpression(path) {
this.traverse(path);
if (
path.node.callee.type === 'MemberExpression' &&
path.node.callee.property.type === 'Identifier' &&
isExpressionExtension(path.node.callee.property.name)
) {
path.replace(
types.builders.callExpression(types.builders.identifier(EXPRESSION_EXTENDER), [
path.node.callee.object,
types.builders.stringLiteral(path.node.callee.property.name),
types.builders.arrayExpression(path.node.arguments),
]),
);
} else if (
path.node.callee.type === 'Identifier' &&
path.node.callee.name === '$if' &&
path.node.arguments.every((v) => v.type !== 'SpreadElement')
) {
if (path.node.arguments.length < 2) {
throw new ExpressionExtensionError(
'$if requires at least 2 parameters: test, value_if_true[, and value_if_false]',
);
}
const test = path.node.arguments[0];
const consequent = path.node.arguments[1];
const alternative =
path.node.arguments[2] === undefined
? types.builders.booleanLiteral(false)
: path.node.arguments[2];
path.replace(
types.builders.conditionalExpression(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
test as any,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
consequent as any,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
alternative as any,
),
);
}
},
});
return print(ast);
} catch (e) {
return;
}
};
function isDate(input: unknown): boolean {
if (typeof input !== 'string' || !input.length) {
return false;
}
if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(input)) {
return false;
}
const d = new Date(input);
return d instanceof Date && !isNaN(d.valueOf()) && d.toISOString() === input;
}
interface FoundFunction {
type: 'native' | 'extended';
// eslint-disable-next-line @typescript-eslint/no-restricted-types
function: Function;
}
function findExtendedFunction(input: unknown, functionName: string): FoundFunction | undefined {
// eslint-disable-next-line @typescript-eslint/no-restricted-types
let foundFunction: Function | undefined;
if (Array.isArray(input)) {
foundFunction = arrayExtensions.functions[functionName];
} else if (isDate(input) && functionName !== 'toDate' && functionName !== 'toDateTime') {
// If it's a string date (from $json), convert it to a Date object,
// unless that function is `toDate`, since `toDate` does something
// very different on date objects
input = new Date(input as string);
foundFunction = dateExtensions.functions[functionName];
} else if (typeof input === 'string') {
foundFunction = stringExtensions.functions[functionName];
} else if (typeof input === 'number') {
foundFunction = numberExtensions.functions[functionName];
} else if (input && (DateTime.isDateTime(input) || input instanceof Date)) {
foundFunction = dateExtensions.functions[functionName];
} else if (input !== null && typeof input === 'object') {
foundFunction = objectExtensions.functions[functionName];
} else if (typeof input === 'boolean') {
foundFunction = booleanExtensions.functions[functionName];
}
// Look for generic or builtin
if (!foundFunction) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const inputAny: any = input;
// This is likely a builtin we're implementing for another type
// (e.g. toLocaleString). We'll return that instead
if (inputAny && functionName && typeof inputAny[functionName] === 'function') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
return { type: 'native', function: inputAny[functionName] };
}
// Use a generic version if available
foundFunction = genericExtensions[functionName];
}
if (!foundFunction) {
return undefined;
}
return { type: 'extended', function: foundFunction };
}
/**
* Extender function injected by expression extension plugin to allow calls to extensions.
*
* ```ts
* extend(input, "functionName", [...args]);
* ```
*/
export function extend(input: unknown, functionName: string, args: unknown[]) {
const foundFunction = findExtendedFunction(input, functionName);
// No type specific or generic function found. Check to see if
// any types have a function with that name. Then throw an error
// letting the user know the available types.
if (!foundFunction) {
checkIfValueDefinedOrThrow(input, functionName);
const haveFunction = EXTENSION_OBJECTS.filter((v) => functionName in v.functions);
if (!haveFunction.length) {
// This shouldn't really be possible but we should cover it anyway
throw new ExpressionExtensionError(`Unknown expression function: ${functionName}`);
}
if (haveFunction.length > 1) {
const lastType = `"${haveFunction.pop()!.typeName}"`;
const typeNames = `${haveFunction.map((v) => `"${v.typeName}"`).join(', ')}, and ${lastType}`;
throw new ExpressionExtensionError(
`${functionName}() is only callable on types ${typeNames}`,
);
} else {
throw new ExpressionExtensionError(
`${functionName}() is only callable on type "${haveFunction[0].typeName}"`,
);
}
}
if (foundFunction.type === 'native') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return foundFunction.function.apply(input, args);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return foundFunction.function(input, args);
}
export function extendOptional(
input: unknown,
functionName: string,
// eslint-disable-next-line @typescript-eslint/no-restricted-types
): Function | undefined {
const foundFunction = findExtendedFunction(input, functionName);
if (!foundFunction) {
return undefined;
}
if (foundFunction.type === 'native') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return foundFunction.function.bind(input);
}
return (...args: unknown[]) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return foundFunction.function(input, args);
};
}
const EXTENDED_SYNTAX_CACHE: Record<string, string> = {};
export function extendSyntax(bracketedExpression: string, forceExtend = false): string {
const chunks = splitExpression(bracketedExpression);
const codeChunks = chunks
.filter((c) => c.type === 'code')
.map((c) => c.text.replace(/("|').*?("|')/, '').trim());
if (
(!codeChunks.some(hasExpressionExtension) || hasNativeMethod(bracketedExpression)) &&
!forceExtend
) {
return bracketedExpression;
}
// If we've seen this expression before grab it from the cache
if (bracketedExpression in EXTENDED_SYNTAX_CACHE) {
return EXTENDED_SYNTAX_CACHE[bracketedExpression];
}
const extendedChunks = chunks.map((chunk): ExpressionChunk => {
if (chunk.type === 'code') {
let output = extendTransform(chunk.text);
// esprima fails to parse bare objects (e.g. `{ data: something }`), we can
// work around this by wrapping it in an parentheses
if (!output?.code && chunk.text.trim()[0] === '{') {
output = extendTransform(`(${chunk.text})`);
}
if (!output?.code) {
throw new ExpressionExtensionError('invalid syntax');
}
let text = output.code;
// We need to cut off any trailing semicolons. These cause issues
// with certain types of expression and cause the whole expression
// to fail.
if (text.trim().endsWith(';')) {
text = text.trim().slice(0, -1);
}
return {
...chunk,
text,
} as ExpressionCode;
}
return chunk;
});
const expression = joinExpression(extendedChunks);
// Cache the expression so we don't have to do this transform again
EXTENDED_SYNTAX_CACHE[bracketedExpression] = expression;
return expression;
}