@object-ui/core 3.3.0 → 3.3.2

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.
Files changed (101) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +20 -1
  3. package/dist/actions/ActionRunner.d.ts +9 -0
  4. package/dist/actions/ActionRunner.js +41 -4
  5. package/dist/adapters/ValueDataSource.js +3 -1
  6. package/dist/registry/Registry.d.ts +47 -0
  7. package/dist/registry/Registry.js +92 -0
  8. package/dist/utils/filter-converter.js +25 -5
  9. package/package.json +32 -8
  10. package/.turbo/turbo-build.log +0 -4
  11. package/src/__benchmarks__/core.bench.ts +0 -64
  12. package/src/__tests__/protocols/DndProtocol.test.ts +0 -186
  13. package/src/__tests__/protocols/KeyboardProtocol.test.ts +0 -177
  14. package/src/__tests__/protocols/NotificationProtocol.test.ts +0 -142
  15. package/src/__tests__/protocols/ResponsiveProtocol.test.ts +0 -176
  16. package/src/__tests__/protocols/SharingProtocol.test.ts +0 -188
  17. package/src/actions/ActionEngine.ts +0 -268
  18. package/src/actions/ActionRunner.ts +0 -717
  19. package/src/actions/TransactionManager.ts +0 -521
  20. package/src/actions/UndoManager.ts +0 -215
  21. package/src/actions/__tests__/ActionEngine.test.ts +0 -206
  22. package/src/actions/__tests__/ActionRunner.params.test.ts +0 -134
  23. package/src/actions/__tests__/ActionRunner.test.ts +0 -711
  24. package/src/actions/__tests__/TransactionManager.test.ts +0 -447
  25. package/src/actions/__tests__/UndoManager.test.ts +0 -320
  26. package/src/actions/index.ts +0 -12
  27. package/src/adapters/ApiDataSource.ts +0 -376
  28. package/src/adapters/README.md +0 -180
  29. package/src/adapters/ValueDataSource.ts +0 -459
  30. package/src/adapters/__tests__/ApiDataSource.test.ts +0 -418
  31. package/src/adapters/__tests__/ValueDataSource.test.ts +0 -571
  32. package/src/adapters/__tests__/resolveDataSource.test.ts +0 -144
  33. package/src/adapters/index.ts +0 -15
  34. package/src/adapters/resolveDataSource.ts +0 -79
  35. package/src/builder/__tests__/schema-builder.test.ts +0 -235
  36. package/src/builder/schema-builder.ts +0 -584
  37. package/src/data-scope/DataScopeManager.ts +0 -269
  38. package/src/data-scope/ViewDataProvider.ts +0 -282
  39. package/src/data-scope/__tests__/DataScopeManager.test.ts +0 -211
  40. package/src/data-scope/__tests__/ViewDataProvider.test.ts +0 -270
  41. package/src/data-scope/index.ts +0 -24
  42. package/src/errors/__tests__/errors.test.ts +0 -292
  43. package/src/errors/index.ts +0 -269
  44. package/src/evaluator/ExpressionCache.ts +0 -206
  45. package/src/evaluator/ExpressionContext.ts +0 -118
  46. package/src/evaluator/ExpressionEvaluator.ts +0 -315
  47. package/src/evaluator/FormulaFunctions.ts +0 -398
  48. package/src/evaluator/SafeExpressionParser.ts +0 -893
  49. package/src/evaluator/__tests__/ExpressionCache.test.ts +0 -135
  50. package/src/evaluator/__tests__/ExpressionContext.test.ts +0 -110
  51. package/src/evaluator/__tests__/ExpressionEvaluator.test.ts +0 -558
  52. package/src/evaluator/__tests__/FormulaFunctions.test.ts +0 -447
  53. package/src/evaluator/index.ts +0 -13
  54. package/src/index.ts +0 -38
  55. package/src/protocols/DndProtocol.ts +0 -168
  56. package/src/protocols/KeyboardProtocol.ts +0 -181
  57. package/src/protocols/NotificationProtocol.ts +0 -150
  58. package/src/protocols/ResponsiveProtocol.ts +0 -210
  59. package/src/protocols/SharingProtocol.ts +0 -185
  60. package/src/protocols/index.ts +0 -13
  61. package/src/query/__tests__/query-ast.test.ts +0 -211
  62. package/src/query/__tests__/window-functions.test.ts +0 -275
  63. package/src/query/index.ts +0 -7
  64. package/src/query/query-ast.ts +0 -341
  65. package/src/registry/PluginScopeImpl.ts +0 -259
  66. package/src/registry/PluginSystem.ts +0 -206
  67. package/src/registry/Registry.ts +0 -219
  68. package/src/registry/WidgetRegistry.ts +0 -316
  69. package/src/registry/__tests__/PluginSystem.test.ts +0 -309
  70. package/src/registry/__tests__/Registry.test.ts +0 -293
  71. package/src/registry/__tests__/WidgetRegistry.test.ts +0 -321
  72. package/src/registry/__tests__/plugin-scope-integration.test.ts +0 -283
  73. package/src/theme/ThemeEngine.ts +0 -530
  74. package/src/theme/__tests__/ThemeEngine.test.ts +0 -668
  75. package/src/theme/index.ts +0 -24
  76. package/src/types/index.ts +0 -21
  77. package/src/utils/__tests__/debug-collector.test.ts +0 -102
  78. package/src/utils/__tests__/debug.test.ts +0 -134
  79. package/src/utils/__tests__/expand-fields.test.ts +0 -120
  80. package/src/utils/__tests__/extract-records.test.ts +0 -50
  81. package/src/utils/__tests__/filter-converter.test.ts +0 -118
  82. package/src/utils/__tests__/merge-views-into-objects.test.ts +0 -110
  83. package/src/utils/__tests__/normalize-quick-filter.test.ts +0 -123
  84. package/src/utils/debug-collector.ts +0 -100
  85. package/src/utils/debug.ts +0 -148
  86. package/src/utils/expand-fields.ts +0 -76
  87. package/src/utils/extract-records.ts +0 -33
  88. package/src/utils/filter-converter.ts +0 -133
  89. package/src/utils/merge-views-into-objects.ts +0 -36
  90. package/src/utils/normalize-quick-filter.ts +0 -78
  91. package/src/validation/__tests__/object-validation-engine.test.ts +0 -567
  92. package/src/validation/__tests__/schema-validator.test.ts +0 -118
  93. package/src/validation/__tests__/validation-engine.test.ts +0 -102
  94. package/src/validation/index.ts +0 -10
  95. package/src/validation/schema-validator.ts +0 -344
  96. package/src/validation/validation-engine.ts +0 -528
  97. package/src/validation/validators/index.ts +0 -25
  98. package/src/validation/validators/object-validation-engine.ts +0 -722
  99. package/tsconfig.json +0 -15
  100. package/tsconfig.tsbuildinfo +0 -1
  101. package/vitest.config.ts +0 -2
@@ -1,893 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- /**
10
- * @object-ui/core - Safe Expression Parser
11
- *
12
- * CSP-safe recursive-descent expression parser for the ObjectUI expression engine.
13
- * Replaces the `new Function()` / `eval()`-based expression compilation with a
14
- * sandboxed interpreter that works under strict Content Security Policy headers.
15
- *
16
- * Operator precedence (lowest → highest):
17
- * 1. Ternary a ? b : c
18
- * 2. Nullish a ?? b
19
- * 3. Logical OR a || b
20
- * 4. Logical AND a && b
21
- * 5. Equality === !== == != > < >= <=
22
- * 6. Addition a + b a - b
23
- * 7. Multiplication a * b a / b a % b
24
- * 8. Unary !a -a +a typeof a
25
- * 9. Member a.b a[b] a?.b a?.[b] a(…) a.b(…)
26
- * 10. Primary literals · identifiers · ( expr ) · [ … ]
27
- *
28
- * @module evaluator
29
- * @packageDocumentation
30
- */
31
-
32
- /**
33
- * A safe subset of global JavaScript objects that are always available in
34
- * expressions regardless of the provided context.
35
- *
36
- * SECURITY: Only read-only, non-executable primitive utilities are exposed.
37
- * Constructors (`String`, `Number`, `Boolean`, `Array`) are intentionally
38
- * omitted: they expose a `.constructor` property that resolves to `Function`,
39
- * creating a sandbox-escape path even when `Function` itself is not listed.
40
- * `eval`, `Function`, `window`, `document`, `process`, etc. are NOT included.
41
- */
42
- const SAFE_GLOBALS: Record<string, unknown> = {
43
- Math,
44
- JSON,
45
- parseInt,
46
- parseFloat,
47
- isNaN,
48
- isFinite,
49
- };
50
-
51
- /**
52
- * Property keys that must never be accessed on any object in an expression.
53
- *
54
- * SECURITY: `constructor` reaches `Function`; `__proto__`/`prototype` allow
55
- * prototype-chain manipulation. Access is blocked on both dot and bracket
56
- * notation to prevent sandbox escapes like `obj['constructor']('...')`.
57
- */
58
- const BLOCKED_PROPS: ReadonlySet<string> = new Set([
59
- 'constructor',
60
- '__proto__',
61
- 'prototype',
62
- '__defineGetter__',
63
- '__defineSetter__',
64
- '__lookupGetter__',
65
- '__lookupSetter__',
66
- ]);
67
-
68
- /**
69
- * CSP-safe recursive-descent expression parser.
70
- *
71
- * Call `evaluate(expression, context)` to parse and execute an expression
72
- * string against a data context object without any use of `eval()` or
73
- * `new Function()`.
74
- *
75
- * @example
76
- * ```ts
77
- * const parser = new SafeExpressionParser();
78
- * parser.evaluate('data.amount > 1000', { data: { amount: 1500 } }); // true
79
- * parser.evaluate('stage !== "closed_won" && stage !== "closed_lost"', { stage: 'open' }); // true
80
- * parser.evaluate('items.filter(i => i.active).length', { items: [{active:true},{active:false}] }); // 1
81
- * ```
82
- */
83
- export class SafeExpressionParser {
84
- private source = '';
85
- private pos = 0;
86
- private context: Record<string, unknown> = {};
87
-
88
- /**
89
- * Evaluation guard.
90
- *
91
- * When `false` the parser still advances `this.pos` through the source
92
- * (maintaining correct position for the caller) but suppresses:
93
- * - ReferenceErrors from undefined identifiers
94
- * - actual function / method invocations
95
- * - constructor calls
96
- *
97
- * This implements proper short-circuit semantics for `||`, `&&`, `??`, and
98
- * the ternary operator without needing a separate AST pass.
99
- */
100
- private _evaluating = true;
101
-
102
- // ─── Public API ────────────────────────────────────────────────────────────
103
-
104
- /**
105
- * Evaluate an expression string against a data context.
106
- *
107
- * Safe for use under strict CSP — never uses `eval()` or `new Function()`.
108
- *
109
- * @param expression - The expression to evaluate (without `${}` wrapper)
110
- * @param context - Variables available to the expression
111
- * @returns The evaluated result
112
- * @throws {ReferenceError} When an identifier is not found in the context
113
- * @throws {TypeError} On type mismatches (e.g., calling a non-function)
114
- * @throws {SyntaxError} On malformed expression syntax
115
- */
116
- evaluate(expression: string, context: Record<string, unknown>): unknown {
117
- this.source = expression.trim();
118
- this.pos = 0;
119
- // Safe globals are available but user-provided context takes priority.
120
- this.context = { ...SAFE_GLOBALS, ...context };
121
-
122
- const result = this.parseTernary();
123
- this.skipWhitespace();
124
-
125
- if (this.pos < this.source.length) {
126
- throw new SyntaxError(
127
- `Unexpected token "${this.source[this.pos]}" at position ${this.pos} in expression "${expression}"`
128
- );
129
- }
130
-
131
- return result;
132
- }
133
-
134
- // ─── Character helpers ────────────────────────────────────────────────────
135
-
136
- private skipWhitespace(): void {
137
- while (this.pos < this.source.length && /\s/.test(this.source[this.pos])) {
138
- this.pos++;
139
- }
140
- }
141
-
142
- private peek(offset = 0): string {
143
- return this.source[this.pos + offset] ?? '';
144
- }
145
-
146
- private consume(): string {
147
- return this.source[this.pos++] ?? '';
148
- }
149
-
150
- // ─── Evaluation control helpers ───────────────────────────────────────────
151
-
152
- /**
153
- * Execute `fn` with `_evaluating` temporarily set to `enabled`.
154
- * Restores the previous value even if `fn` throws.
155
- *
156
- * Used to implement short-circuit evaluation: when a branch should not be
157
- * executed we call `withEvaluation(false, parseX)` which advances the source
158
- * position without performing any side-effectful evaluations.
159
- */
160
- private withEvaluation<T>(enabled: boolean, fn: () => T): T {
161
- const prev = this._evaluating;
162
- this._evaluating = enabled;
163
- try {
164
- return fn();
165
- } finally {
166
- this._evaluating = prev;
167
- }
168
- }
169
-
170
- // ─── Security helpers ─────────────────────────────────────────────────────
171
-
172
- /**
173
- * Guard property accesses against sandbox-escape keys.
174
- * Throws `TypeError` when the key is in `BLOCKED_PROPS`.
175
- *
176
- * Only string keys need checking: all blocked property names are strings,
177
- * and `BLOCKED_PROPS.has()` with a number or symbol can never match them.
178
- * Numeric indices (e.g. `arr[0]`) and symbol-keyed properties are therefore
179
- * safe to access and are intentionally left unchecked.
180
- */
181
- private assertSafeProp(key: unknown): void {
182
- if (typeof key === 'string' && BLOCKED_PROPS.has(key)) {
183
- throw new TypeError(
184
- `Access to property "${key}" is not permitted in expressions`
185
- );
186
- }
187
- }
188
-
189
- // ─── Parsing levels ───────────────────────────────────────────────────────
190
-
191
- /** Level 1 — Ternary: `cond ? trueVal : falseVal` (right-associative) */
192
- private parseTernary(): unknown {
193
- const cond = this.parseNullish();
194
- this.skipWhitespace();
195
-
196
- if (this.peek() === '?' && this.peek(1) !== '?') {
197
- this.pos++; // consume '?'
198
- this.skipWhitespace();
199
-
200
- if (!this._evaluating) {
201
- // Dry-run mode: parse both branches for position tracking only.
202
- this.parseTernary(); // true branch (positional advance)
203
- this.skipWhitespace();
204
- if (this.peek() !== ':') {
205
- throw new SyntaxError('Expected ":" in ternary expression');
206
- }
207
- this.pos++; // consume ':'
208
- this.skipWhitespace();
209
- this.parseTernary(); // false branch (positional advance)
210
- return undefined;
211
- }
212
-
213
- if (cond) {
214
- // Evaluate true branch; skip false branch without side effects.
215
- const trueVal = this.parseTernary();
216
- this.skipWhitespace();
217
- if (this.peek() !== ':') {
218
- throw new SyntaxError('Expected ":" in ternary expression');
219
- }
220
- this.pos++; // consume ':'
221
- this.skipWhitespace();
222
- this.withEvaluation(false, () => this.parseTernary()); // skip false
223
- return trueVal;
224
- } else {
225
- // Skip true branch without side effects; evaluate false branch.
226
- this.withEvaluation(false, () => this.parseTernary()); // skip true
227
- this.skipWhitespace();
228
- if (this.peek() !== ':') {
229
- throw new SyntaxError('Expected ":" in ternary expression');
230
- }
231
- this.pos++; // consume ':'
232
- this.skipWhitespace();
233
- return this.parseTernary(); // evaluate false
234
- }
235
- }
236
-
237
- return cond;
238
- }
239
-
240
- /** Level 2 — Nullish coalescing: `a ?? b` */
241
- private parseNullish(): unknown {
242
- let left = this.parseOr();
243
- this.skipWhitespace();
244
-
245
- while (this.peek() === '?' && this.peek(1) === '?') {
246
- this.pos += 2;
247
- this.skipWhitespace();
248
-
249
- // Short-circuit: left is non-nullish — skip RHS without evaluating it.
250
- if (this._evaluating && left != null) {
251
- this.withEvaluation(false, () => this.parseOr());
252
- } else {
253
- const right = this.parseOr();
254
- if (this._evaluating) left = left ?? right;
255
- }
256
-
257
- this.skipWhitespace();
258
- }
259
-
260
- return left;
261
- }
262
-
263
- /** Level 3 — Logical OR: `a || b` */
264
- private parseOr(): unknown {
265
- let left = this.parseAnd();
266
- this.skipWhitespace();
267
-
268
- while (this.peek() === '|' && this.peek(1) === '|') {
269
- this.pos += 2;
270
- this.skipWhitespace();
271
-
272
- // Short-circuit: left is truthy — skip RHS without evaluating it.
273
- if (this._evaluating && left) {
274
- this.withEvaluation(false, () => this.parseAnd());
275
- } else {
276
- const right = this.parseAnd();
277
- if (this._evaluating) left = left || right;
278
- }
279
-
280
- this.skipWhitespace();
281
- }
282
-
283
- return left;
284
- }
285
-
286
- /** Level 4 — Logical AND: `a && b` */
287
- private parseAnd(): unknown {
288
- let left = this.parseEquality();
289
- this.skipWhitespace();
290
-
291
- while (this.peek() === '&' && this.peek(1) === '&') {
292
- this.pos += 2;
293
- this.skipWhitespace();
294
-
295
- // Short-circuit: left is falsy — skip RHS without evaluating it.
296
- if (this._evaluating && !left) {
297
- this.withEvaluation(false, () => this.parseEquality());
298
- } else {
299
- const right = this.parseEquality();
300
- if (this._evaluating) left = left && right;
301
- }
302
-
303
- this.skipWhitespace();
304
- }
305
-
306
- return left;
307
- }
308
-
309
- /** Level 5 — Equality and relational comparisons */
310
- private parseEquality(): unknown {
311
- let left = this.parseAddition();
312
- this.skipWhitespace();
313
-
314
- // eslint-disable-next-line no-constant-condition
315
- while (true) {
316
- let op: string | null = null;
317
-
318
- if (this.peek() === '=' && this.peek(1) === '=' && this.peek(2) === '=') {
319
- op = '==='; this.pos += 3;
320
- } else if (this.peek() === '!' && this.peek(1) === '=' && this.peek(2) === '=') {
321
- op = '!=='; this.pos += 3;
322
- } else if (this.peek() === '=' && this.peek(1) === '=') {
323
- op = '=='; this.pos += 2;
324
- } else if (this.peek() === '!' && this.peek(1) === '=') {
325
- op = '!='; this.pos += 2;
326
- } else if (this.peek() === '>' && this.peek(1) === '=') {
327
- op = '>='; this.pos += 2;
328
- } else if (this.peek() === '<' && this.peek(1) === '=') {
329
- op = '<='; this.pos += 2;
330
- } else if (this.peek() === '>' && this.peek(1) !== '>') {
331
- op = '>'; this.pos++;
332
- } else if (this.peek() === '<' && this.peek(1) !== '<') {
333
- op = '<'; this.pos++;
334
- } else {
335
- break;
336
- }
337
-
338
- this.skipWhitespace();
339
- const right = this.parseAddition();
340
-
341
- switch (op) {
342
- case '===': left = left === right; break;
343
- case '!==': left = left !== right; break;
344
- // eslint-disable-next-line eqeqeq
345
- case '==': left = (left as any) == (right as any); break;
346
- // eslint-disable-next-line eqeqeq
347
- case '!=': left = (left as any) != (right as any); break;
348
- case '>': left = (left as any) > (right as any); break;
349
- case '<': left = (left as any) < (right as any); break;
350
- case '>=': left = (left as any) >= (right as any); break;
351
- case '<=': left = (left as any) <= (right as any); break;
352
- }
353
-
354
- this.skipWhitespace();
355
- }
356
-
357
- return left;
358
- }
359
-
360
- /** Level 6 — Addition / Subtraction */
361
- private parseAddition(): unknown {
362
- let left = this.parseMultiplication();
363
- this.skipWhitespace();
364
-
365
- while (
366
- (this.peek() === '+' || this.peek() === '-') &&
367
- this.peek(1) !== '=' // avoid consuming += / -=
368
- ) {
369
- const op = this.consume();
370
- this.skipWhitespace();
371
- const right = this.parseMultiplication();
372
- left = op === '+' ? (left as any) + (right as any) : (left as any) - (right as any);
373
- this.skipWhitespace();
374
- }
375
-
376
- return left;
377
- }
378
-
379
- /** Level 7 — Multiplication / Division / Modulo */
380
- private parseMultiplication(): unknown {
381
- let left = this.parseUnary();
382
- this.skipWhitespace();
383
-
384
- while (
385
- (this.peek() === '*' || this.peek() === '/' || this.peek() === '%') &&
386
- this.peek(1) !== '='
387
- ) {
388
- const op = this.consume();
389
- this.skipWhitespace();
390
- const right = this.parseUnary();
391
- if (op === '*') left = (left as any) * (right as any);
392
- else if (op === '/') left = (left as any) / (right as any);
393
- else left = (left as any) % (right as any);
394
- this.skipWhitespace();
395
- }
396
-
397
- return left;
398
- }
399
-
400
- /** Level 8 — Unary operators: `!`, `-`, `+`, `typeof` */
401
- private parseUnary(): unknown {
402
- this.skipWhitespace();
403
-
404
- // typeof (must be checked before identifier parsing to avoid consuming it)
405
- if (
406
- this.source.startsWith('typeof', this.pos) &&
407
- !/[\w$]/.test(this.source[this.pos + 6] ?? '')
408
- ) {
409
- this.pos += 6;
410
- this.skipWhitespace();
411
- try {
412
- return typeof this.parseUnary();
413
- } catch {
414
- // typeof undeclaredVar === 'undefined' in real JS
415
- return 'undefined';
416
- }
417
- }
418
-
419
- if (this.peek() === '!') {
420
- this.pos++;
421
- return !this.parseUnary();
422
- }
423
-
424
- if (this.peek() === '-') {
425
- this.pos++;
426
- return -(this.parseUnary() as any);
427
- }
428
-
429
- if (this.peek() === '+') {
430
- this.pos++;
431
- return +(this.parseUnary() as any);
432
- }
433
-
434
- return this.parseMember();
435
- }
436
-
437
- /** Level 9 — Member access, method calls, function calls */
438
- private parseMember(): unknown {
439
- let obj = this.parsePrimary();
440
-
441
- // eslint-disable-next-line no-constant-condition
442
- while (true) {
443
- this.skipWhitespace();
444
-
445
- const isOptionalDot = this.peek() === '?' && this.peek(1) === '.';
446
- const isDot = this.peek() === '.' && this.peek(1) !== '.';
447
-
448
- if (isDot || isOptionalDot) {
449
- // Property / method access: obj.prop or obj?.prop
450
- if (isOptionalDot) this.pos++; // consume '?' for ?.
451
- this.pos++; // consume '.'
452
- this.skipWhitespace();
453
-
454
- const prop = this.parseIdentifierName();
455
- if (!prop) break;
456
-
457
- // Block sandbox-escape properties regardless of evaluation mode.
458
- this.assertSafeProp(prop);
459
-
460
- this.skipWhitespace();
461
-
462
- if (this.peek() === '(') {
463
- // Method call: obj.method(args)
464
- this.pos++; // consume '('
465
- const args = this.parseArgList();
466
- if (this.peek() !== ')') {
467
- throw new SyntaxError(`Expected ")" after argument list at position ${this.pos}`);
468
- }
469
- this.pos++; // consume ')'
470
-
471
- if (!this._evaluating) { obj = undefined; continue; }
472
-
473
- if (obj != null && typeof (obj as any)[prop] === 'function') {
474
- obj = ((obj as any)[prop] as (...a: unknown[]) => unknown)(...args);
475
- } else {
476
- obj = undefined;
477
- }
478
- } else {
479
- // Property access
480
- if (!this._evaluating) { obj = undefined; continue; }
481
- obj = obj != null ? (obj as any)[prop] : undefined;
482
- }
483
-
484
- continue;
485
- }
486
-
487
- const isOptionalBracket = this.peek() === '?' && this.peek(1) === '[';
488
- const isBracket = this.peek() === '[';
489
-
490
- if (isBracket || isOptionalBracket) {
491
- // Bracket access: obj[key] or obj?.[key]
492
- if (isOptionalBracket) this.pos++; // consume '?' for ?.[
493
- this.pos++; // consume '['
494
- this.skipWhitespace();
495
- const key = this.parseTernary();
496
- this.skipWhitespace();
497
- if (this.peek() !== ']') {
498
- throw new SyntaxError(`Expected "]" after bracket expression at position ${this.pos}`);
499
- }
500
- this.pos++; // consume ']'
501
-
502
- // Block sandbox-escape properties regardless of evaluation mode.
503
- this.assertSafeProp(key);
504
-
505
- if (!this._evaluating) { obj = undefined; continue; }
506
- obj = obj != null ? (obj as any)[key as string | number] : undefined;
507
- continue;
508
- }
509
-
510
- if (this.peek() === '(') {
511
- // Direct function call on a returned value, e.g. (getFunc())(args)
512
- this.pos++; // consume '('
513
- const args = this.parseArgList();
514
- if (this.peek() !== ')') {
515
- throw new SyntaxError(`Expected ")" after argument list at position ${this.pos}`);
516
- }
517
- this.pos++; // consume ')'
518
-
519
- if (!this._evaluating) { obj = undefined; continue; }
520
-
521
- if (typeof obj === 'function') {
522
- obj = (obj as (...a: unknown[]) => unknown)(...args);
523
- } else {
524
- throw new TypeError(`${String(obj)} is not a function`);
525
- }
526
- continue;
527
- }
528
-
529
- break;
530
- }
531
-
532
- return obj;
533
- }
534
-
535
- /** Level 10 — Primary expressions: literals, identifiers, `(expr)`, `[…]` */
536
- private parsePrimary(): unknown {
537
- this.skipWhitespace();
538
- const ch = this.peek();
539
-
540
- // Parenthesized expression
541
- if (ch === '(') {
542
- this.pos++;
543
- const val = this.parseTernary();
544
- this.skipWhitespace();
545
- if (this.peek() !== ')') {
546
- throw new SyntaxError(
547
- `Expected ")" to close "(" expression at position ${this.pos}`
548
- );
549
- }
550
- this.pos++;
551
- return val;
552
- }
553
-
554
- // Array literal
555
- if (ch === '[') {
556
- return this.parseArrayLiteral();
557
- }
558
-
559
- // String literals
560
- if (ch === '"' || ch === "'") {
561
- return this.parseString(ch);
562
- }
563
-
564
- // Number literals (unary `-` is handled in parseUnary, not here)
565
- if (/\d/.test(ch) || (ch === '.' && /\d/.test(this.peek(1)))) {
566
- return this.parseNumber();
567
- }
568
-
569
- // Identifiers and keywords
570
- if (/[a-zA-Z_$]/.test(ch)) {
571
- return this.parseIdentifierOrKeyword();
572
- }
573
-
574
- throw new SyntaxError(
575
- `Unexpected character "${ch}" at position ${this.pos}`
576
- );
577
- }
578
-
579
- // ─── Literal parsers ──────────────────────────────────────────────────────
580
-
581
- private parseArrayLiteral(): unknown[] {
582
- this.pos++; // consume '['
583
- const items: unknown[] = [];
584
- this.skipWhitespace();
585
-
586
- while (this.peek() !== ']' && this.pos < this.source.length) {
587
- items.push(this.parseTernary());
588
- this.skipWhitespace();
589
- if (this.peek() === ',') {
590
- this.pos++;
591
- this.skipWhitespace();
592
- }
593
- }
594
-
595
- if (this.peek() !== ']') {
596
- throw new SyntaxError(`Expected "]" to close array literal at position ${this.pos}`);
597
- }
598
- this.pos++;
599
- return items;
600
- }
601
-
602
- private parseString(quote: string): string {
603
- this.pos++; // consume opening quote
604
- let str = '';
605
-
606
- while (this.pos < this.source.length) {
607
- const ch = this.source[this.pos];
608
-
609
- if (ch === '\\') {
610
- this.pos++;
611
- const esc = this.source[this.pos++] ?? '';
612
- switch (esc) {
613
- case 'n': str += '\n'; break;
614
- case 't': str += '\t'; break;
615
- case 'r': str += '\r'; break;
616
- case '\\': str += '\\'; break;
617
- case '"': str += '"'; break;
618
- case "'": str += "'"; break;
619
- case '`': str += '`'; break;
620
- default: str += esc;
621
- }
622
- } else if (ch === quote) {
623
- this.pos++; // consume closing quote
624
- break;
625
- } else {
626
- str += ch;
627
- this.pos++;
628
- }
629
- }
630
-
631
- return str;
632
- }
633
-
634
- private parseNumber(): number {
635
- const start = this.pos;
636
- let hasDigits = false;
637
-
638
- // Note: `parsePrimary` only calls this method when the first character is
639
- // a digit OR when it is '.' followed immediately by a digit, so the case
640
- // of a bare '.' (e.g. `.toString()`) can never reach here.
641
-
642
- // Integer part
643
- while (this.pos < this.source.length && /\d/.test(this.source[this.pos])) {
644
- hasDigits = true;
645
- this.pos++;
646
- }
647
-
648
- // Optional fractional part (only one decimal point is consumed; a second
649
- // '.' is left in the stream and will cause an "unexpected token" error at
650
- // the `evaluate()` level, correctly rejecting inputs like `1.2.3`).
651
- if (this.source[this.pos] === '.') {
652
- this.pos++; // consume '.'
653
- while (this.pos < this.source.length && /\d/.test(this.source[this.pos])) {
654
- hasDigits = true;
655
- this.pos++;
656
- }
657
- }
658
-
659
- // Optional exponent: e+5, E-3
660
- if (/[eE]/.test(this.source[this.pos] ?? '')) {
661
- this.pos++; // consume 'e' or 'E'
662
- if (/[+\-]/.test(this.source[this.pos] ?? '')) this.pos++; // optional sign
663
-
664
- let expDigits = 0;
665
- while (this.pos < this.source.length && /\d/.test(this.source[this.pos])) {
666
- expDigits++;
667
- this.pos++;
668
- }
669
- if (expDigits === 0) {
670
- throw new SyntaxError(`Invalid numeric literal exponent at position ${start}`);
671
- }
672
- }
673
-
674
- if (!hasDigits) {
675
- throw new SyntaxError(`Invalid numeric literal at position ${start}`);
676
- }
677
-
678
- const raw = this.source.slice(start, this.pos);
679
- const value = Number(raw);
680
-
681
- // Defensive final check: the strict loop above should never produce a
682
- // non-finite result, but we guard here so any latent bug surfaces as a
683
- // clear SyntaxError rather than silently propagating NaN.
684
- if (!Number.isFinite(value)) {
685
- throw new SyntaxError(`Invalid numeric literal "${raw}" at position ${start}`);
686
- }
687
-
688
- return value;
689
- }
690
-
691
- // ─── Identifier / keyword parsing ────────────────────────────────────────
692
-
693
- /**
694
- * Parse an identifier name (stops at non-word characters).
695
- * Does NOT consume any trailing whitespace or operators.
696
- */
697
- private parseIdentifierName(): string {
698
- const start = this.pos;
699
- while (this.pos < this.source.length && /[\w$]/.test(this.source[this.pos])) {
700
- this.pos++;
701
- }
702
- return this.source.slice(start, this.pos);
703
- }
704
-
705
- /** Parse an identifier and resolve keywords, `new`, arrows, calls, lookups. */
706
- private parseIdentifierOrKeyword(): unknown {
707
- const id = this.parseIdentifierName();
708
-
709
- // Literal keywords
710
- switch (id) {
711
- case 'true': return true;
712
- case 'false': return false;
713
- case 'null': return null;
714
- case 'undefined': return undefined;
715
- case 'NaN': return NaN;
716
- case 'Infinity': return Infinity;
717
- }
718
-
719
- // `new` keyword: new Date(), new RegExp(...)
720
- if (id === 'new') {
721
- return this.parseNewExpression();
722
- }
723
-
724
- this.skipWhitespace();
725
-
726
- // Single-param arrow function without parentheses: `param => body`
727
- if (this.peek() === '=' && this.peek(1) === '>') {
728
- return this.parseArrowFunction(id);
729
- }
730
-
731
- // Function call: FN(args)
732
- if (this.peek() === '(') {
733
- this.pos++; // consume '('
734
- const args = this.parseArgList();
735
- if (this.peek() !== ')') {
736
- throw new SyntaxError(`Expected ")" after argument list at position ${this.pos}`);
737
- }
738
- this.pos++; // consume ')'
739
-
740
- if (!this._evaluating) return undefined;
741
-
742
- const fn = this.context[id];
743
- if (typeof fn === 'function') {
744
- return (fn as (...a: unknown[]) => unknown)(...args);
745
- }
746
- throw new TypeError(`"${id}" is not a function`);
747
- }
748
-
749
- // Variable lookup.
750
- // In not-evaluating mode return undefined instead of throwing ReferenceError,
751
- // so that short-circuited branches do not cause spurious errors.
752
- if (!this._evaluating) return undefined;
753
-
754
- if (!(id in this.context)) {
755
- throw new ReferenceError(`${id} is not defined`);
756
- }
757
-
758
- return this.context[id];
759
- }
760
-
761
- /**
762
- * Handle `new ConstructorName(args)` expressions.
763
- * Only safe constructors (Date, RegExp) are permitted.
764
- */
765
- private parseNewExpression(): unknown {
766
- this.skipWhitespace();
767
- const constructorName = this.parseIdentifierName();
768
- this.skipWhitespace();
769
-
770
- let args: unknown[] = [];
771
- if (this.peek() === '(') {
772
- this.pos++; // consume '('
773
- args = this.parseArgList();
774
- if (this.peek() !== ')') {
775
- throw new SyntaxError(`Expected ")" after new ${constructorName}() at position ${this.pos}`);
776
- }
777
- this.pos++; // consume ')'
778
- }
779
-
780
- if (!this._evaluating) return undefined;
781
-
782
- switch (constructorName) {
783
- case 'Date':
784
- return new Date(...(args as ConstructorParameters<typeof Date>));
785
- case 'RegExp':
786
- return new RegExp(args[0] as string, args[1] as string | undefined);
787
- default:
788
- throw new TypeError(
789
- `new ${constructorName}() is not supported in expressions`
790
- );
791
- }
792
- }
793
-
794
- /**
795
- * Parse a single-param arrow function: `param => bodyExpression`
796
- *
797
- * The body is captured as a source substring (without evaluating it at
798
- * parse time), so that the parameter is properly bound when the returned
799
- * function is later invoked (e.g., inside `.filter()`, `.map()`, etc.).
800
- */
801
- private parseArrowFunction(param: string): (...args: unknown[]) => unknown {
802
- this.pos += 2; // consume '=>'
803
- this.skipWhitespace();
804
-
805
- // Scan the source to find where the body expression ends (depth-0 comma
806
- // or closing bracket/paren), without evaluating it.
807
- const bodyStart = this.pos;
808
- const bodyEnd = this.scanExpressionEnd();
809
- const bodyStr = this.source.slice(bodyStart, bodyEnd).trim();
810
- this.pos = bodyEnd;
811
-
812
- // Capture the outer context by value so the returned function can use it.
813
- const capturedContext = this.context;
814
-
815
- return (arg: unknown) => {
816
- const parser = new SafeExpressionParser();
817
- return parser.evaluate(bodyStr, {
818
- ...capturedContext,
819
- [param]: arg,
820
- });
821
- };
822
- }
823
-
824
- /**
825
- * Scan forward from the current position to find the end of a sub-expression
826
- * without evaluating it. Stops when a depth-0 `,`, `)`, or `]` is found.
827
- * Correctly skips over string literals and nested brackets.
828
- *
829
- * @returns The index just past the last character of the sub-expression.
830
- */
831
- private scanExpressionEnd(): number {
832
- let i = this.pos;
833
- let depth = 0;
834
- let inString = false;
835
- let stringChar = '';
836
-
837
- while (i < this.source.length) {
838
- const ch = this.source[i];
839
-
840
- if (inString) {
841
- if (ch === '\\') {
842
- i += 2; // skip escaped character
843
- continue;
844
- }
845
- if (ch === stringChar) {
846
- inString = false;
847
- }
848
- i++;
849
- continue;
850
- }
851
-
852
- if (ch === '"' || ch === "'") {
853
- inString = true;
854
- stringChar = ch;
855
- i++;
856
- continue;
857
- }
858
-
859
- if (ch === '(' || ch === '[') {
860
- depth++;
861
- } else if (ch === ')' || ch === ']') {
862
- if (depth === 0) break; // end of this sub-expression
863
- depth--;
864
- } else if (ch === ',' && depth === 0) {
865
- break; // argument separator
866
- }
867
-
868
- i++;
869
- }
870
-
871
- return i;
872
- }
873
-
874
- /**
875
- * Parse a comma-separated argument list up to (but not including) the
876
- * closing `)`.
877
- */
878
- private parseArgList(): unknown[] {
879
- const args: unknown[] = [];
880
- this.skipWhitespace();
881
-
882
- while (this.peek() !== ')' && this.pos < this.source.length) {
883
- args.push(this.parseTernary());
884
- this.skipWhitespace();
885
- if (this.peek() === ',') {
886
- this.pos++;
887
- this.skipWhitespace();
888
- }
889
- }
890
-
891
- return args;
892
- }
893
- }