@webmate-studio/builder 0.2.110 → 0.2.113
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/package.json +1 -1
- package/src/design-tokens.js +16 -5
- package/src/expression-evaluator.js +1234 -0
- package/src/markdown.js +22 -3
- package/src/template-evaluator.js +379 -0
- package/src/template-parser.js +597 -0
- package/src/template-processor.js +87 -582
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expression Evaluator - Safe AST-based Expression Parser
|
|
3
|
+
*
|
|
4
|
+
* Universal (isomorphic) module for Browser and Node.js.
|
|
5
|
+
* Supports a safe subset of JavaScript expressions:
|
|
6
|
+
*
|
|
7
|
+
* - Math: +, -, *, /, %
|
|
8
|
+
* - Comparison: >, <, >=, <=, ===, !==, ==, !=
|
|
9
|
+
* - Logical: &&, ||, !
|
|
10
|
+
* - Ternary: condition ? a : b
|
|
11
|
+
* - Nullish coalescing: a ?? b
|
|
12
|
+
* - Property access: obj.prop, obj?.prop, obj[index]
|
|
13
|
+
* - Function calls (whitelisted): items.filter(x => x.active)
|
|
14
|
+
* - Arrow functions: (x) => x.active, x => x + 1
|
|
15
|
+
* - Array literals: [1, 2, 3]
|
|
16
|
+
* - Object literals: { key: value }
|
|
17
|
+
* - Template literals: `hello ${name}`
|
|
18
|
+
* - Spread: ...items
|
|
19
|
+
* - typeof operator
|
|
20
|
+
*
|
|
21
|
+
* Security:
|
|
22
|
+
* - No eval(), no Function constructor
|
|
23
|
+
* - No access to window, document, globalThis, process
|
|
24
|
+
* - No assignments (=, +=, etc.)
|
|
25
|
+
* - No import/require
|
|
26
|
+
* - Method calls only on whitelisted prototypes
|
|
27
|
+
*
|
|
28
|
+
* @module expression-evaluator
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// ============================================================
|
|
32
|
+
// Token Types
|
|
33
|
+
// ============================================================
|
|
34
|
+
|
|
35
|
+
const T = {
|
|
36
|
+
NUMBER: 1,
|
|
37
|
+
STRING: 2,
|
|
38
|
+
IDENTIFIER: 3,
|
|
39
|
+
TRUE: 4,
|
|
40
|
+
FALSE: 5,
|
|
41
|
+
NULL: 6,
|
|
42
|
+
UNDEFINED: 7,
|
|
43
|
+
PLUS: 8,
|
|
44
|
+
MINUS: 9,
|
|
45
|
+
STAR: 10,
|
|
46
|
+
SLASH: 11,
|
|
47
|
+
PERCENT: 12,
|
|
48
|
+
GT: 13,
|
|
49
|
+
LT: 14,
|
|
50
|
+
GTE: 15,
|
|
51
|
+
LTE: 16,
|
|
52
|
+
EQ_STRICT: 17,
|
|
53
|
+
NEQ_STRICT: 18,
|
|
54
|
+
EQ_LOOSE: 19,
|
|
55
|
+
NEQ_LOOSE: 20,
|
|
56
|
+
AND: 21,
|
|
57
|
+
OR: 22,
|
|
58
|
+
NOT: 23,
|
|
59
|
+
QUESTION: 24,
|
|
60
|
+
COLON: 25,
|
|
61
|
+
DOT: 26,
|
|
62
|
+
OPTIONAL_CHAIN: 27,
|
|
63
|
+
LPAREN: 28,
|
|
64
|
+
RPAREN: 29,
|
|
65
|
+
LBRACKET: 30,
|
|
66
|
+
RBRACKET: 31,
|
|
67
|
+
LBRACE: 32,
|
|
68
|
+
RBRACE: 33,
|
|
69
|
+
COMMA: 34,
|
|
70
|
+
ARROW: 35,
|
|
71
|
+
SPREAD: 36,
|
|
72
|
+
TYPEOF: 37,
|
|
73
|
+
NULLISH: 38,
|
|
74
|
+
TEMPLATE: 39,
|
|
75
|
+
EOF: 40,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ============================================================
|
|
79
|
+
// Whitelisted methods for safe execution
|
|
80
|
+
// ============================================================
|
|
81
|
+
|
|
82
|
+
const ALLOWED_ARRAY_METHODS = new Set([
|
|
83
|
+
'filter', 'map', 'find', 'findIndex', 'some', 'every',
|
|
84
|
+
'includes', 'indexOf', 'lastIndexOf',
|
|
85
|
+
'slice', 'concat', 'flat', 'flatMap',
|
|
86
|
+
'join', 'reverse', 'sort',
|
|
87
|
+
'reduce', 'reduceRight',
|
|
88
|
+
'fill', 'from', 'isArray', 'of',
|
|
89
|
+
'at', 'entries', 'keys', 'values',
|
|
90
|
+
'forEach',
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
const ALLOWED_STRING_METHODS = new Set([
|
|
94
|
+
'toLowerCase', 'toUpperCase', 'trim', 'trimStart', 'trimEnd',
|
|
95
|
+
'startsWith', 'endsWith', 'includes', 'indexOf', 'lastIndexOf',
|
|
96
|
+
'split', 'replace', 'replaceAll', 'substring', 'slice',
|
|
97
|
+
'charAt', 'charCodeAt', 'padStart', 'padEnd', 'repeat',
|
|
98
|
+
'match', 'search', 'at',
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const ALLOWED_NUMBER_METHODS = new Set([
|
|
102
|
+
'toFixed', 'toPrecision', 'toString',
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
const ALLOWED_OBJECT_STATIC = new Set([
|
|
106
|
+
'keys', 'values', 'entries', 'assign', 'fromEntries',
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
const ALLOWED_MATH_PROPS = new Set([
|
|
110
|
+
'floor', 'ceil', 'round', 'abs', 'min', 'max',
|
|
111
|
+
'pow', 'sqrt', 'sign', 'trunc', 'random',
|
|
112
|
+
'PI', 'E',
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const ALLOWED_JSON_METHODS = new Set([
|
|
116
|
+
'stringify', 'parse',
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
const ALLOWED_GLOBALS = new Set([
|
|
120
|
+
'parseInt', 'parseFloat', 'isNaN', 'isFinite',
|
|
121
|
+
'Number', 'String', 'Boolean', 'Array', 'Object',
|
|
122
|
+
'Math', 'JSON', 'Date', 'console',
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
const BLOCKED_IDENTIFIERS = new Set([
|
|
126
|
+
'window', 'document', 'globalThis', 'self',
|
|
127
|
+
'process', 'require', 'import', 'module', 'exports',
|
|
128
|
+
'eval', 'Function', 'setTimeout', 'setInterval',
|
|
129
|
+
'fetch', 'XMLHttpRequest', 'WebSocket',
|
|
130
|
+
'__proto__', 'constructor', 'prototype',
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
// ============================================================
|
|
134
|
+
// Lexer
|
|
135
|
+
// ============================================================
|
|
136
|
+
|
|
137
|
+
class Lexer {
|
|
138
|
+
constructor(input) {
|
|
139
|
+
this.input = input;
|
|
140
|
+
this.pos = 0;
|
|
141
|
+
this.length = input.length;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
peek(offset = 0) {
|
|
145
|
+
const p = this.pos + offset;
|
|
146
|
+
return p < this.length ? this.input[p] : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
advance(n = 1) {
|
|
150
|
+
this.pos += n;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
skipWhitespace() {
|
|
154
|
+
while (this.pos < this.length && /\s/.test(this.input[this.pos])) {
|
|
155
|
+
this.pos++;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
readNumber() {
|
|
160
|
+
const start = this.pos;
|
|
161
|
+
// Handle hex: 0x...
|
|
162
|
+
if (this.input[this.pos] === '0' && (this.input[this.pos + 1] === 'x' || this.input[this.pos + 1] === 'X')) {
|
|
163
|
+
this.pos += 2;
|
|
164
|
+
while (this.pos < this.length && /[0-9a-fA-F]/.test(this.input[this.pos])) this.pos++;
|
|
165
|
+
return { type: T.NUMBER, value: parseInt(this.input.slice(start, this.pos), 16) };
|
|
166
|
+
}
|
|
167
|
+
while (this.pos < this.length && /[0-9]/.test(this.input[this.pos])) this.pos++;
|
|
168
|
+
if (this.pos < this.length && this.input[this.pos] === '.' && /[0-9]/.test(this.input[this.pos + 1] || '')) {
|
|
169
|
+
this.pos++;
|
|
170
|
+
while (this.pos < this.length && /[0-9]/.test(this.input[this.pos])) this.pos++;
|
|
171
|
+
}
|
|
172
|
+
// Scientific notation
|
|
173
|
+
if (this.pos < this.length && (this.input[this.pos] === 'e' || this.input[this.pos] === 'E')) {
|
|
174
|
+
this.pos++;
|
|
175
|
+
if (this.pos < this.length && (this.input[this.pos] === '+' || this.input[this.pos] === '-')) this.pos++;
|
|
176
|
+
while (this.pos < this.length && /[0-9]/.test(this.input[this.pos])) this.pos++;
|
|
177
|
+
}
|
|
178
|
+
return { type: T.NUMBER, value: parseFloat(this.input.slice(start, this.pos)) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
readString(quote) {
|
|
182
|
+
this.pos++; // skip opening quote
|
|
183
|
+
let str = '';
|
|
184
|
+
while (this.pos < this.length && this.input[this.pos] !== quote) {
|
|
185
|
+
if (this.input[this.pos] === '\\') {
|
|
186
|
+
this.pos++;
|
|
187
|
+
if (this.pos >= this.length) break;
|
|
188
|
+
const c = this.input[this.pos];
|
|
189
|
+
switch (c) {
|
|
190
|
+
case 'n': str += '\n'; break;
|
|
191
|
+
case 't': str += '\t'; break;
|
|
192
|
+
case 'r': str += '\r'; break;
|
|
193
|
+
case '\\': str += '\\'; break;
|
|
194
|
+
case "'": str += "'"; break;
|
|
195
|
+
case '"': str += '"'; break;
|
|
196
|
+
case '`': str += '`'; break;
|
|
197
|
+
default: str += c;
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
str += this.input[this.pos];
|
|
201
|
+
}
|
|
202
|
+
this.pos++;
|
|
203
|
+
}
|
|
204
|
+
this.pos++; // skip closing quote
|
|
205
|
+
return str;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
readTemplateLiteral() {
|
|
209
|
+
this.pos++; // skip opening backtick
|
|
210
|
+
const parts = []; // Array of { type: 'text', value } or { type: 'expr', value }
|
|
211
|
+
let text = '';
|
|
212
|
+
|
|
213
|
+
while (this.pos < this.length && this.input[this.pos] !== '`') {
|
|
214
|
+
if (this.input[this.pos] === '\\') {
|
|
215
|
+
this.pos++;
|
|
216
|
+
if (this.pos >= this.length) break;
|
|
217
|
+
const c = this.input[this.pos];
|
|
218
|
+
switch (c) {
|
|
219
|
+
case 'n': text += '\n'; break;
|
|
220
|
+
case 't': text += '\t'; break;
|
|
221
|
+
case '`': text += '`'; break;
|
|
222
|
+
case '$': text += '$'; break;
|
|
223
|
+
case '\\': text += '\\'; break;
|
|
224
|
+
default: text += c;
|
|
225
|
+
}
|
|
226
|
+
this.pos++;
|
|
227
|
+
} else if (this.input[this.pos] === '$' && this.input[this.pos + 1] === '{') {
|
|
228
|
+
// Template expression
|
|
229
|
+
if (text) {
|
|
230
|
+
parts.push({ type: 'text', value: text });
|
|
231
|
+
text = '';
|
|
232
|
+
}
|
|
233
|
+
this.pos += 2; // skip ${
|
|
234
|
+
|
|
235
|
+
// Find matching } (handle nesting)
|
|
236
|
+
let depth = 1;
|
|
237
|
+
let exprStart = this.pos;
|
|
238
|
+
while (this.pos < this.length && depth > 0) {
|
|
239
|
+
if (this.input[this.pos] === '{') depth++;
|
|
240
|
+
else if (this.input[this.pos] === '}') depth--;
|
|
241
|
+
if (depth > 0) this.pos++;
|
|
242
|
+
}
|
|
243
|
+
parts.push({ type: 'expr', value: this.input.slice(exprStart, this.pos) });
|
|
244
|
+
this.pos++; // skip }
|
|
245
|
+
} else {
|
|
246
|
+
text += this.input[this.pos];
|
|
247
|
+
this.pos++;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
this.pos++; // skip closing backtick
|
|
251
|
+
|
|
252
|
+
if (text) {
|
|
253
|
+
parts.push({ type: 'text', value: text });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { type: T.TEMPLATE, parts };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
readIdentifier() {
|
|
260
|
+
const start = this.pos;
|
|
261
|
+
while (this.pos < this.length && /[a-zA-Z0-9_$]/.test(this.input[this.pos])) {
|
|
262
|
+
this.pos++;
|
|
263
|
+
}
|
|
264
|
+
return this.input.slice(start, this.pos);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
getNextToken() {
|
|
268
|
+
this.skipWhitespace();
|
|
269
|
+
if (this.pos >= this.length) return { type: T.EOF };
|
|
270
|
+
|
|
271
|
+
const ch = this.input[this.pos];
|
|
272
|
+
const next = this.pos + 1 < this.length ? this.input[this.pos + 1] : null;
|
|
273
|
+
const next2 = this.pos + 2 < this.length ? this.input[this.pos + 2] : null;
|
|
274
|
+
|
|
275
|
+
// Numbers
|
|
276
|
+
if (/[0-9]/.test(ch)) return this.readNumber();
|
|
277
|
+
|
|
278
|
+
// Strings
|
|
279
|
+
if (ch === '"' || ch === "'") return { type: T.STRING, value: this.readString(ch) };
|
|
280
|
+
|
|
281
|
+
// Template literals
|
|
282
|
+
if (ch === '`') return this.readTemplateLiteral();
|
|
283
|
+
|
|
284
|
+
// Spread operator
|
|
285
|
+
if (ch === '.' && next === '.' && next2 === '.') {
|
|
286
|
+
this.advance(3);
|
|
287
|
+
return { type: T.SPREAD };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Identifiers and keywords
|
|
291
|
+
if (/[a-zA-Z_$]/.test(ch)) {
|
|
292
|
+
const id = this.readIdentifier();
|
|
293
|
+
switch (id) {
|
|
294
|
+
case 'true': return { type: T.TRUE, value: true };
|
|
295
|
+
case 'false': return { type: T.FALSE, value: false };
|
|
296
|
+
case 'null': return { type: T.NULL, value: null };
|
|
297
|
+
case 'undefined': return { type: T.UNDEFINED, value: undefined };
|
|
298
|
+
case 'typeof': return { type: T.TYPEOF };
|
|
299
|
+
default: return { type: T.IDENTIFIER, value: id };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Multi-character operators
|
|
304
|
+
switch (ch) {
|
|
305
|
+
case '=':
|
|
306
|
+
if (next === '=' && next2 === '=') { this.advance(3); return { type: T.EQ_STRICT }; }
|
|
307
|
+
if (next === '=') { this.advance(2); return { type: T.EQ_LOOSE }; }
|
|
308
|
+
if (next === '>') { this.advance(2); return { type: T.ARROW }; }
|
|
309
|
+
throw new Error(`Assignment operator = not allowed in expressions`);
|
|
310
|
+
case '!':
|
|
311
|
+
if (next === '=' && next2 === '=') { this.advance(3); return { type: T.NEQ_STRICT }; }
|
|
312
|
+
if (next === '=') { this.advance(2); return { type: T.NEQ_LOOSE }; }
|
|
313
|
+
this.advance(); return { type: T.NOT };
|
|
314
|
+
case '>':
|
|
315
|
+
if (next === '=') { this.advance(2); return { type: T.GTE }; }
|
|
316
|
+
this.advance(); return { type: T.GT };
|
|
317
|
+
case '<':
|
|
318
|
+
if (next === '=') { this.advance(2); return { type: T.LTE }; }
|
|
319
|
+
this.advance(); return { type: T.LT };
|
|
320
|
+
case '&':
|
|
321
|
+
if (next === '&') { this.advance(2); return { type: T.AND }; }
|
|
322
|
+
throw new Error('Bitwise & not supported, use &&');
|
|
323
|
+
case '|':
|
|
324
|
+
if (next === '|') { this.advance(2); return { type: T.OR }; }
|
|
325
|
+
throw new Error('Bitwise | not supported, use ||');
|
|
326
|
+
case '?':
|
|
327
|
+
if (next === '?') { this.advance(2); return { type: T.NULLISH }; }
|
|
328
|
+
if (next === '.') { this.advance(2); return { type: T.OPTIONAL_CHAIN }; }
|
|
329
|
+
this.advance(); return { type: T.QUESTION };
|
|
330
|
+
case '+': this.advance(); return { type: T.PLUS };
|
|
331
|
+
case '-': this.advance(); return { type: T.MINUS };
|
|
332
|
+
case '*': this.advance(); return { type: T.STAR };
|
|
333
|
+
case '/': this.advance(); return { type: T.SLASH };
|
|
334
|
+
case '%': this.advance(); return { type: T.PERCENT };
|
|
335
|
+
case '(': this.advance(); return { type: T.LPAREN };
|
|
336
|
+
case ')': this.advance(); return { type: T.RPAREN };
|
|
337
|
+
case '[': this.advance(); return { type: T.LBRACKET };
|
|
338
|
+
case ']': this.advance(); return { type: T.RBRACKET };
|
|
339
|
+
case '{': this.advance(); return { type: T.LBRACE };
|
|
340
|
+
case '}': this.advance(); return { type: T.RBRACE };
|
|
341
|
+
case ',': this.advance(); return { type: T.COMMA };
|
|
342
|
+
case '.': this.advance(); return { type: T.DOT };
|
|
343
|
+
case ':': this.advance(); return { type: T.COLON };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
throw new Error(`Unexpected character: ${ch} at position ${this.pos}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ============================================================
|
|
351
|
+
// Parser — Builds AST from token stream
|
|
352
|
+
// ============================================================
|
|
353
|
+
|
|
354
|
+
class Parser {
|
|
355
|
+
constructor(lexer) {
|
|
356
|
+
this.lexer = lexer;
|
|
357
|
+
this.current = this.lexer.getNextToken();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
eat(type) {
|
|
361
|
+
if (this.current.type === type) {
|
|
362
|
+
const token = this.current;
|
|
363
|
+
this.current = this.lexer.getNextToken();
|
|
364
|
+
return token;
|
|
365
|
+
}
|
|
366
|
+
throw new Error(`Expected token type ${type}, got ${this.current.type}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Entry point
|
|
370
|
+
parse() {
|
|
371
|
+
const node = this.expression();
|
|
372
|
+
if (this.current.type !== T.EOF) {
|
|
373
|
+
throw new Error(`Unexpected token after expression`);
|
|
374
|
+
}
|
|
375
|
+
return node;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// expression: assignment-level (we don't allow assignment, so this is ternary/nullish)
|
|
379
|
+
expression() {
|
|
380
|
+
return this.ternary();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ternary: nullishCoalescing (? expression : expression)?
|
|
384
|
+
ternary() {
|
|
385
|
+
let node = this.nullishCoalescing();
|
|
386
|
+
|
|
387
|
+
if (this.current.type === T.QUESTION) {
|
|
388
|
+
this.eat(T.QUESTION);
|
|
389
|
+
const consequent = this.expression();
|
|
390
|
+
this.eat(T.COLON);
|
|
391
|
+
const alternate = this.expression();
|
|
392
|
+
return { type: 'Conditional', test: node, consequent, alternate };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return node;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// nullishCoalescing: logicalOr (?? logicalOr)*
|
|
399
|
+
nullishCoalescing() {
|
|
400
|
+
let node = this.logicalOr();
|
|
401
|
+
|
|
402
|
+
while (this.current.type === T.NULLISH) {
|
|
403
|
+
this.eat(T.NULLISH);
|
|
404
|
+
node = { type: 'Nullish', left: node, right: this.logicalOr() };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return node;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// logicalOr: logicalAnd (|| logicalAnd)*
|
|
411
|
+
logicalOr() {
|
|
412
|
+
let node = this.logicalAnd();
|
|
413
|
+
|
|
414
|
+
while (this.current.type === T.OR) {
|
|
415
|
+
this.eat(T.OR);
|
|
416
|
+
node = { type: 'Logical', op: '||', left: node, right: this.logicalAnd() };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return node;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// logicalAnd: comparison (&& comparison)*
|
|
423
|
+
logicalAnd() {
|
|
424
|
+
let node = this.comparison();
|
|
425
|
+
|
|
426
|
+
while (this.current.type === T.AND) {
|
|
427
|
+
this.eat(T.AND);
|
|
428
|
+
node = { type: 'Logical', op: '&&', left: node, right: this.comparison() };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return node;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// comparison: additive (comp_op additive)*
|
|
435
|
+
comparison() {
|
|
436
|
+
let node = this.additive();
|
|
437
|
+
|
|
438
|
+
const compTypes = [T.GT, T.LT, T.GTE, T.LTE, T.EQ_STRICT, T.NEQ_STRICT, T.EQ_LOOSE, T.NEQ_LOOSE];
|
|
439
|
+
while (compTypes.includes(this.current.type)) {
|
|
440
|
+
const opMap = {
|
|
441
|
+
[T.GT]: '>', [T.LT]: '<', [T.GTE]: '>=', [T.LTE]: '<=',
|
|
442
|
+
[T.EQ_STRICT]: '===', [T.NEQ_STRICT]: '!==',
|
|
443
|
+
[T.EQ_LOOSE]: '==', [T.NEQ_LOOSE]: '!=',
|
|
444
|
+
};
|
|
445
|
+
const op = opMap[this.current.type];
|
|
446
|
+
this.eat(this.current.type);
|
|
447
|
+
node = { type: 'Binary', op, left: node, right: this.additive() };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return node;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// additive: multiplicative ((+|-) multiplicative)*
|
|
454
|
+
additive() {
|
|
455
|
+
let node = this.multiplicative();
|
|
456
|
+
|
|
457
|
+
while (this.current.type === T.PLUS || this.current.type === T.MINUS) {
|
|
458
|
+
const op = this.current.type === T.PLUS ? '+' : '-';
|
|
459
|
+
this.eat(this.current.type);
|
|
460
|
+
node = { type: 'Binary', op, left: node, right: this.multiplicative() };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return node;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// multiplicative: unary ((*|/|%) unary)*
|
|
467
|
+
multiplicative() {
|
|
468
|
+
let node = this.unary();
|
|
469
|
+
|
|
470
|
+
while (this.current.type === T.STAR || this.current.type === T.SLASH || this.current.type === T.PERCENT) {
|
|
471
|
+
const opMap = { [T.STAR]: '*', [T.SLASH]: '/', [T.PERCENT]: '%' };
|
|
472
|
+
const op = opMap[this.current.type];
|
|
473
|
+
this.eat(this.current.type);
|
|
474
|
+
node = { type: 'Binary', op, left: node, right: this.unary() };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return node;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// unary: (!|+|-|typeof|...) unary | postfix
|
|
481
|
+
unary() {
|
|
482
|
+
if (this.current.type === T.NOT) {
|
|
483
|
+
this.eat(T.NOT);
|
|
484
|
+
return { type: 'Unary', op: '!', argument: this.unary() };
|
|
485
|
+
}
|
|
486
|
+
if (this.current.type === T.MINUS) {
|
|
487
|
+
this.eat(T.MINUS);
|
|
488
|
+
return { type: 'Unary', op: '-', argument: this.unary() };
|
|
489
|
+
}
|
|
490
|
+
if (this.current.type === T.PLUS) {
|
|
491
|
+
this.eat(T.PLUS);
|
|
492
|
+
return { type: 'Unary', op: '+', argument: this.unary() };
|
|
493
|
+
}
|
|
494
|
+
if (this.current.type === T.TYPEOF) {
|
|
495
|
+
this.eat(T.TYPEOF);
|
|
496
|
+
return { type: 'Typeof', argument: this.unary() };
|
|
497
|
+
}
|
|
498
|
+
if (this.current.type === T.SPREAD) {
|
|
499
|
+
this.eat(T.SPREAD);
|
|
500
|
+
return { type: 'Spread', argument: this.unary() };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return this.callOrMember();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// callOrMember: primary (. prop | ?. prop | [expr] | (args))*
|
|
507
|
+
callOrMember() {
|
|
508
|
+
let node = this.primary();
|
|
509
|
+
|
|
510
|
+
while (true) {
|
|
511
|
+
if (this.current.type === T.DOT) {
|
|
512
|
+
this.eat(T.DOT);
|
|
513
|
+
const prop = this.eat(T.IDENTIFIER).value;
|
|
514
|
+
node = { type: 'Member', object: node, property: prop, computed: false, optional: false };
|
|
515
|
+
} else if (this.current.type === T.OPTIONAL_CHAIN) {
|
|
516
|
+
this.eat(T.OPTIONAL_CHAIN);
|
|
517
|
+
if (this.current.type === T.LBRACKET) {
|
|
518
|
+
// ?.[ computed access
|
|
519
|
+
this.eat(T.LBRACKET);
|
|
520
|
+
const expr = this.expression();
|
|
521
|
+
this.eat(T.RBRACKET);
|
|
522
|
+
node = { type: 'Member', object: node, property: expr, computed: true, optional: true };
|
|
523
|
+
} else if (this.current.type === T.LPAREN) {
|
|
524
|
+
// ?.( optional call
|
|
525
|
+
const args = this.parseArguments();
|
|
526
|
+
node = { type: 'Call', callee: node, arguments: args, optional: true };
|
|
527
|
+
} else {
|
|
528
|
+
const prop = this.eat(T.IDENTIFIER).value;
|
|
529
|
+
node = { type: 'Member', object: node, property: prop, computed: false, optional: true };
|
|
530
|
+
}
|
|
531
|
+
} else if (this.current.type === T.LBRACKET) {
|
|
532
|
+
this.eat(T.LBRACKET);
|
|
533
|
+
const expr = this.expression();
|
|
534
|
+
this.eat(T.RBRACKET);
|
|
535
|
+
node = { type: 'Member', object: node, property: expr, computed: true, optional: false };
|
|
536
|
+
} else if (this.current.type === T.LPAREN) {
|
|
537
|
+
const args = this.parseArguments();
|
|
538
|
+
node = { type: 'Call', callee: node, arguments: args, optional: false };
|
|
539
|
+
} else {
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return node;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
parseArguments() {
|
|
548
|
+
this.eat(T.LPAREN);
|
|
549
|
+
const args = [];
|
|
550
|
+
while (this.current.type !== T.RPAREN) {
|
|
551
|
+
if (this.current.type === T.SPREAD) {
|
|
552
|
+
this.eat(T.SPREAD);
|
|
553
|
+
args.push({ type: 'Spread', argument: this.expression() });
|
|
554
|
+
} else {
|
|
555
|
+
args.push(this.expression());
|
|
556
|
+
}
|
|
557
|
+
if (this.current.type === T.COMMA) {
|
|
558
|
+
this.eat(T.COMMA);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
this.eat(T.RPAREN);
|
|
562
|
+
return args;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// primary: literals, identifiers, grouped, arrays, objects, arrow functions
|
|
566
|
+
primary() {
|
|
567
|
+
const tok = this.current;
|
|
568
|
+
|
|
569
|
+
// Number
|
|
570
|
+
if (tok.type === T.NUMBER) {
|
|
571
|
+
this.eat(T.NUMBER);
|
|
572
|
+
return { type: 'Literal', value: tok.value };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// String
|
|
576
|
+
if (tok.type === T.STRING) {
|
|
577
|
+
this.eat(T.STRING);
|
|
578
|
+
return { type: 'Literal', value: tok.value };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Template literal
|
|
582
|
+
if (tok.type === T.TEMPLATE) {
|
|
583
|
+
this.eat(T.TEMPLATE);
|
|
584
|
+
return { type: 'TemplateLiteral', parts: tok.parts };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Boolean / null / undefined
|
|
588
|
+
if (tok.type === T.TRUE || tok.type === T.FALSE) {
|
|
589
|
+
this.eat(tok.type);
|
|
590
|
+
return { type: 'Literal', value: tok.value };
|
|
591
|
+
}
|
|
592
|
+
if (tok.type === T.NULL) {
|
|
593
|
+
this.eat(T.NULL);
|
|
594
|
+
return { type: 'Literal', value: null };
|
|
595
|
+
}
|
|
596
|
+
if (tok.type === T.UNDEFINED) {
|
|
597
|
+
this.eat(T.UNDEFINED);
|
|
598
|
+
return { type: 'Literal', value: undefined };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Identifier (might be start of arrow function: ident => ...)
|
|
602
|
+
if (tok.type === T.IDENTIFIER) {
|
|
603
|
+
this.eat(T.IDENTIFIER);
|
|
604
|
+
// Check for arrow function: ident => body
|
|
605
|
+
if (this.current.type === T.ARROW) {
|
|
606
|
+
this.eat(T.ARROW);
|
|
607
|
+
const body = this.expression();
|
|
608
|
+
return { type: 'Arrow', params: [tok.value], body };
|
|
609
|
+
}
|
|
610
|
+
return { type: 'Identifier', name: tok.value };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Grouped expression or arrow function params: (a, b) => ...
|
|
614
|
+
if (tok.type === T.LPAREN) {
|
|
615
|
+
return this.parseParenOrArrow();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Array literal: [a, b, c]
|
|
619
|
+
if (tok.type === T.LBRACKET) {
|
|
620
|
+
return this.parseArray();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Object literal: { key: value }
|
|
624
|
+
if (tok.type === T.LBRACE) {
|
|
625
|
+
return this.parseObject();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
throw new Error(`Unexpected token: ${tok.type} (value: ${JSON.stringify(tok.value)})`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
parseParenOrArrow() {
|
|
632
|
+
this.eat(T.LPAREN);
|
|
633
|
+
|
|
634
|
+
// Empty parens: () => ...
|
|
635
|
+
if (this.current.type === T.RPAREN) {
|
|
636
|
+
this.eat(T.RPAREN);
|
|
637
|
+
this.eat(T.ARROW);
|
|
638
|
+
const body = this.expression();
|
|
639
|
+
return { type: 'Arrow', params: [], body };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Try to parse as arrow function params
|
|
643
|
+
// Save position for backtracking
|
|
644
|
+
const savedPos = this.lexer.pos;
|
|
645
|
+
const savedCurrent = { ...this.current };
|
|
646
|
+
|
|
647
|
+
// Collect potential params
|
|
648
|
+
const params = [];
|
|
649
|
+
let isArrow = true;
|
|
650
|
+
let firstExpr = null;
|
|
651
|
+
|
|
652
|
+
try {
|
|
653
|
+
// First item
|
|
654
|
+
if (this.current.type === T.SPREAD) {
|
|
655
|
+
// Rest parameter: (...args) => ...
|
|
656
|
+
this.eat(T.SPREAD);
|
|
657
|
+
params.push({ type: 'rest', name: this.eat(T.IDENTIFIER).value });
|
|
658
|
+
} else if (this.current.type === T.IDENTIFIER) {
|
|
659
|
+
params.push(this.current.value);
|
|
660
|
+
this.eat(T.IDENTIFIER);
|
|
661
|
+
} else {
|
|
662
|
+
// Not a simple param list — it's a grouped expression
|
|
663
|
+
isArrow = false;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (isArrow) {
|
|
667
|
+
while (this.current.type === T.COMMA) {
|
|
668
|
+
this.eat(T.COMMA);
|
|
669
|
+
if (this.current.type === T.SPREAD) {
|
|
670
|
+
this.eat(T.SPREAD);
|
|
671
|
+
params.push({ type: 'rest', name: this.eat(T.IDENTIFIER).value });
|
|
672
|
+
} else if (this.current.type === T.IDENTIFIER) {
|
|
673
|
+
params.push(this.current.value);
|
|
674
|
+
this.eat(T.IDENTIFIER);
|
|
675
|
+
} else {
|
|
676
|
+
isArrow = false;
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (isArrow && this.current.type === T.RPAREN) {
|
|
683
|
+
this.eat(T.RPAREN);
|
|
684
|
+
if (this.current.type === T.ARROW) {
|
|
685
|
+
this.eat(T.ARROW);
|
|
686
|
+
const body = this.expression();
|
|
687
|
+
return { type: 'Arrow', params: params.map(p => typeof p === 'string' ? p : p), body };
|
|
688
|
+
}
|
|
689
|
+
// It was (identifier) but no arrow — treat as grouped expression
|
|
690
|
+
if (params.length === 1 && typeof params[0] === 'string') {
|
|
691
|
+
return { type: 'Identifier', name: params[0] };
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
} catch (e) {
|
|
695
|
+
isArrow = false;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Backtrack and parse as grouped expression
|
|
699
|
+
if (!isArrow || (isArrow && this.current.type !== T.EOF)) {
|
|
700
|
+
// We can't easily backtrack with our lexer, so re-lex
|
|
701
|
+
this.lexer.pos = savedPos;
|
|
702
|
+
this.current = savedCurrent;
|
|
703
|
+
|
|
704
|
+
// It's just a regular grouped expression if we get here with a single non-arrow identifier
|
|
705
|
+
// But we already consumed LPAREN, so parse the expression
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Parse as grouped expression
|
|
709
|
+
const expr = this.expression();
|
|
710
|
+
this.eat(T.RPAREN);
|
|
711
|
+
|
|
712
|
+
// Check if this is actually arrow: (expr) =>
|
|
713
|
+
if (this.current.type === T.ARROW) {
|
|
714
|
+
this.eat(T.ARROW);
|
|
715
|
+
const body = this.expression();
|
|
716
|
+
// expr should be an Identifier
|
|
717
|
+
if (expr.type === 'Identifier') {
|
|
718
|
+
return { type: 'Arrow', params: [expr.name], body };
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return expr;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
parseArray() {
|
|
726
|
+
this.eat(T.LBRACKET);
|
|
727
|
+
const elements = [];
|
|
728
|
+
while (this.current.type !== T.RBRACKET) {
|
|
729
|
+
if (this.current.type === T.SPREAD) {
|
|
730
|
+
this.eat(T.SPREAD);
|
|
731
|
+
elements.push({ type: 'Spread', argument: this.expression() });
|
|
732
|
+
} else {
|
|
733
|
+
elements.push(this.expression());
|
|
734
|
+
}
|
|
735
|
+
if (this.current.type === T.COMMA) {
|
|
736
|
+
this.eat(T.COMMA);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
this.eat(T.RBRACKET);
|
|
740
|
+
return { type: 'ArrayLiteral', elements };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
parseObject() {
|
|
744
|
+
this.eat(T.LBRACE);
|
|
745
|
+
const properties = [];
|
|
746
|
+
while (this.current.type !== T.RBRACE) {
|
|
747
|
+
if (this.current.type === T.SPREAD) {
|
|
748
|
+
this.eat(T.SPREAD);
|
|
749
|
+
properties.push({ type: 'SpreadProperty', argument: this.expression() });
|
|
750
|
+
} else {
|
|
751
|
+
let key;
|
|
752
|
+
let computed = false;
|
|
753
|
+
|
|
754
|
+
if (this.current.type === T.LBRACKET) {
|
|
755
|
+
// Computed key: { [expr]: value }
|
|
756
|
+
this.eat(T.LBRACKET);
|
|
757
|
+
key = this.expression();
|
|
758
|
+
this.eat(T.RBRACKET);
|
|
759
|
+
computed = true;
|
|
760
|
+
} else if (this.current.type === T.STRING) {
|
|
761
|
+
key = this.eat(T.STRING).value;
|
|
762
|
+
} else if (this.current.type === T.NUMBER) {
|
|
763
|
+
key = String(this.eat(T.NUMBER).value);
|
|
764
|
+
} else {
|
|
765
|
+
key = this.eat(T.IDENTIFIER).value;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (this.current.type === T.COLON) {
|
|
769
|
+
this.eat(T.COLON);
|
|
770
|
+
const value = this.expression();
|
|
771
|
+
properties.push({ type: 'Property', key, value, computed });
|
|
772
|
+
} else {
|
|
773
|
+
// Shorthand: { foo } is { foo: foo }
|
|
774
|
+
properties.push({
|
|
775
|
+
type: 'Property',
|
|
776
|
+
key,
|
|
777
|
+
value: { type: 'Identifier', name: key },
|
|
778
|
+
computed: false,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (this.current.type === T.COMMA) {
|
|
783
|
+
this.eat(T.COMMA);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
this.eat(T.RBRACE);
|
|
787
|
+
return { type: 'ObjectLiteral', properties };
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ============================================================
|
|
792
|
+
// Evaluator — Evaluates AST with a context
|
|
793
|
+
// ============================================================
|
|
794
|
+
|
|
795
|
+
class Evaluator {
|
|
796
|
+
constructor(context) {
|
|
797
|
+
this.context = context;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
evaluate(node) {
|
|
801
|
+
if (!node) return undefined;
|
|
802
|
+
|
|
803
|
+
switch (node.type) {
|
|
804
|
+
case 'Literal':
|
|
805
|
+
return node.value;
|
|
806
|
+
|
|
807
|
+
case 'Identifier':
|
|
808
|
+
return this.resolveIdentifier(node.name);
|
|
809
|
+
|
|
810
|
+
case 'TemplateLiteral':
|
|
811
|
+
return this.evaluateTemplateLiteral(node);
|
|
812
|
+
|
|
813
|
+
case 'Binary':
|
|
814
|
+
return this.evaluateBinary(node);
|
|
815
|
+
|
|
816
|
+
case 'Logical':
|
|
817
|
+
return this.evaluateLogical(node);
|
|
818
|
+
|
|
819
|
+
case 'Unary':
|
|
820
|
+
return this.evaluateUnary(node);
|
|
821
|
+
|
|
822
|
+
case 'Typeof':
|
|
823
|
+
return this.evaluateTypeof(node);
|
|
824
|
+
|
|
825
|
+
case 'Conditional':
|
|
826
|
+
return this.evaluate(node.test) ? this.evaluate(node.consequent) : this.evaluate(node.alternate);
|
|
827
|
+
|
|
828
|
+
case 'Nullish': {
|
|
829
|
+
const left = this.evaluate(node.left);
|
|
830
|
+
return left != null ? left : this.evaluate(node.right);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
case 'Member':
|
|
834
|
+
return this.evaluateMember(node);
|
|
835
|
+
|
|
836
|
+
case 'Call':
|
|
837
|
+
return this.evaluateCall(node);
|
|
838
|
+
|
|
839
|
+
case 'Arrow':
|
|
840
|
+
return this.evaluateArrow(node);
|
|
841
|
+
|
|
842
|
+
case 'ArrayLiteral':
|
|
843
|
+
return this.evaluateArray(node);
|
|
844
|
+
|
|
845
|
+
case 'ObjectLiteral':
|
|
846
|
+
return this.evaluateObject(node);
|
|
847
|
+
|
|
848
|
+
case 'Spread':
|
|
849
|
+
return this.evaluate(node.argument);
|
|
850
|
+
|
|
851
|
+
default:
|
|
852
|
+
throw new Error(`Unknown AST node type: ${node.type}`);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
resolveIdentifier(name) {
|
|
857
|
+
if (BLOCKED_IDENTIFIERS.has(name)) {
|
|
858
|
+
throw new Error(`Access to '${name}' is not allowed`);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Built-in globals
|
|
862
|
+
if (name === 'Math') return Math;
|
|
863
|
+
if (name === 'JSON') return JSON;
|
|
864
|
+
if (name === 'parseInt') return parseInt;
|
|
865
|
+
if (name === 'parseFloat') return parseFloat;
|
|
866
|
+
if (name === 'isNaN') return isNaN;
|
|
867
|
+
if (name === 'isFinite') return isFinite;
|
|
868
|
+
if (name === 'Number') return Number;
|
|
869
|
+
if (name === 'String') return String;
|
|
870
|
+
if (name === 'Boolean') return Boolean;
|
|
871
|
+
if (name === 'Array') return Array;
|
|
872
|
+
if (name === 'Object') return Object;
|
|
873
|
+
if (name === 'Date') return Date;
|
|
874
|
+
if (name === 'Infinity') return Infinity;
|
|
875
|
+
if (name === 'NaN') return NaN;
|
|
876
|
+
|
|
877
|
+
// Context lookup
|
|
878
|
+
if (name in this.context) {
|
|
879
|
+
return this.context[name];
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return undefined;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
evaluateTemplateLiteral(node) {
|
|
886
|
+
return node.parts.map(part => {
|
|
887
|
+
if (part.type === 'text') return part.value;
|
|
888
|
+
// Parse and evaluate the expression inside ${}
|
|
889
|
+
const innerAst = new Parser(new Lexer(part.value)).parse();
|
|
890
|
+
return String(this.evaluate(innerAst) ?? '');
|
|
891
|
+
}).join('');
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
evaluateBinary(node) {
|
|
895
|
+
const left = this.evaluate(node.left);
|
|
896
|
+
const right = this.evaluate(node.right);
|
|
897
|
+
|
|
898
|
+
switch (node.op) {
|
|
899
|
+
case '+': return left + right;
|
|
900
|
+
case '-': return left - right;
|
|
901
|
+
case '*': return left * right;
|
|
902
|
+
case '/': return left / right;
|
|
903
|
+
case '%': return left % right;
|
|
904
|
+
case '>': return left > right;
|
|
905
|
+
case '<': return left < right;
|
|
906
|
+
case '>=': return left >= right;
|
|
907
|
+
case '<=': return left <= right;
|
|
908
|
+
case '===': return left === right;
|
|
909
|
+
case '!==': return left !== right;
|
|
910
|
+
case '==': return left == right;
|
|
911
|
+
case '!=': return left != right;
|
|
912
|
+
default: throw new Error(`Unknown binary operator: ${node.op}`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
evaluateLogical(node) {
|
|
917
|
+
const left = this.evaluate(node.left);
|
|
918
|
+
if (node.op === '&&') return left ? this.evaluate(node.right) : left;
|
|
919
|
+
if (node.op === '||') return left ? left : this.evaluate(node.right);
|
|
920
|
+
throw new Error(`Unknown logical operator: ${node.op}`);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
evaluateUnary(node) {
|
|
924
|
+
const arg = this.evaluate(node.argument);
|
|
925
|
+
switch (node.op) {
|
|
926
|
+
case '!': return !arg;
|
|
927
|
+
case '-': return -arg;
|
|
928
|
+
case '+': return +arg;
|
|
929
|
+
default: throw new Error(`Unknown unary operator: ${node.op}`);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
evaluateTypeof(node) {
|
|
934
|
+
try {
|
|
935
|
+
const val = this.evaluate(node.argument);
|
|
936
|
+
return typeof val;
|
|
937
|
+
} catch {
|
|
938
|
+
return 'undefined';
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
evaluateMember(node) {
|
|
943
|
+
const object = this.evaluate(node.object);
|
|
944
|
+
|
|
945
|
+
if (node.optional && (object == null)) {
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (object == null) {
|
|
950
|
+
return undefined;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (node.computed) {
|
|
954
|
+
const key = this.evaluate(node.property);
|
|
955
|
+
return object[key];
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const prop = node.property;
|
|
959
|
+
|
|
960
|
+
// Block dangerous properties
|
|
961
|
+
if (prop === '__proto__' || prop === 'constructor' || prop === 'prototype') {
|
|
962
|
+
return undefined;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return object[prop];
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
evaluateCall(node) {
|
|
969
|
+
// Handle method calls: obj.method(args)
|
|
970
|
+
if (node.callee.type === 'Member') {
|
|
971
|
+
const obj = this.evaluate(node.callee.object);
|
|
972
|
+
|
|
973
|
+
if (node.optional && (obj == null)) {
|
|
974
|
+
return undefined;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (obj == null) {
|
|
978
|
+
return undefined;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const methodName = node.callee.computed
|
|
982
|
+
? this.evaluate(node.callee.property)
|
|
983
|
+
: node.callee.property;
|
|
984
|
+
|
|
985
|
+
// Validate method call is allowed
|
|
986
|
+
this.validateMethodCall(obj, methodName);
|
|
987
|
+
|
|
988
|
+
const method = obj[methodName];
|
|
989
|
+
if (typeof method !== 'function') {
|
|
990
|
+
throw new Error(`${methodName} is not a function`);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const args = this.evaluateArgList(node.arguments);
|
|
994
|
+
return method.apply(obj, args);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Handle direct function calls: fn(args) or Array.isArray(x)
|
|
998
|
+
const callee = this.evaluate(node.callee);
|
|
999
|
+
|
|
1000
|
+
if (typeof callee !== 'function') {
|
|
1001
|
+
throw new Error('Not a function');
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const args = this.evaluateArgList(node.arguments);
|
|
1005
|
+
return callee(...args);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
validateMethodCall(obj, methodName) {
|
|
1009
|
+
if (Array.isArray(obj)) {
|
|
1010
|
+
if (!ALLOWED_ARRAY_METHODS.has(methodName)) {
|
|
1011
|
+
throw new Error(`Array method '${methodName}' is not allowed`);
|
|
1012
|
+
}
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (typeof obj === 'string') {
|
|
1017
|
+
if (!ALLOWED_STRING_METHODS.has(methodName)) {
|
|
1018
|
+
throw new Error(`String method '${methodName}' is not allowed`);
|
|
1019
|
+
}
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (typeof obj === 'number') {
|
|
1024
|
+
if (!ALLOWED_NUMBER_METHODS.has(methodName)) {
|
|
1025
|
+
throw new Error(`Number method '${methodName}' is not allowed`);
|
|
1026
|
+
}
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (obj === Math) {
|
|
1031
|
+
if (!ALLOWED_MATH_PROPS.has(methodName)) {
|
|
1032
|
+
throw new Error(`Math.${methodName} is not allowed`);
|
|
1033
|
+
}
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (obj === JSON) {
|
|
1038
|
+
if (!ALLOWED_JSON_METHODS.has(methodName)) {
|
|
1039
|
+
throw new Error(`JSON.${methodName} is not allowed`);
|
|
1040
|
+
}
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (obj === Object) {
|
|
1045
|
+
if (!ALLOWED_OBJECT_STATIC.has(methodName)) {
|
|
1046
|
+
throw new Error(`Object.${methodName} is not allowed`);
|
|
1047
|
+
}
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (obj === Array) {
|
|
1052
|
+
if (!ALLOWED_ARRAY_METHODS.has(methodName) && methodName !== 'from' && methodName !== 'isArray' && methodName !== 'of') {
|
|
1053
|
+
throw new Error(`Array.${methodName} is not allowed`);
|
|
1054
|
+
}
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// For plain objects, allow calling methods that are own properties (user-defined functions)
|
|
1059
|
+
if (typeof obj === 'object' && obj !== null) {
|
|
1060
|
+
if (typeof obj[methodName] === 'function') {
|
|
1061
|
+
// Allow if it's an own property or from a safe prototype
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
throw new Error(`Method call '${methodName}' is not allowed on this object type`);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
evaluateArgList(args) {
|
|
1070
|
+
const result = [];
|
|
1071
|
+
for (const arg of args) {
|
|
1072
|
+
if (arg.type === 'Spread') {
|
|
1073
|
+
const spreadVal = this.evaluate(arg.argument);
|
|
1074
|
+
if (Array.isArray(spreadVal)) {
|
|
1075
|
+
result.push(...spreadVal);
|
|
1076
|
+
} else {
|
|
1077
|
+
result.push(spreadVal);
|
|
1078
|
+
}
|
|
1079
|
+
} else {
|
|
1080
|
+
result.push(this.evaluate(arg));
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return result;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
evaluateArrow(node) {
|
|
1087
|
+
const outerContext = this.context;
|
|
1088
|
+
|
|
1089
|
+
return (...args) => {
|
|
1090
|
+
const innerContext = { ...outerContext };
|
|
1091
|
+
|
|
1092
|
+
for (let i = 0; i < node.params.length; i++) {
|
|
1093
|
+
const param = node.params[i];
|
|
1094
|
+
if (typeof param === 'object' && param.type === 'rest') {
|
|
1095
|
+
innerContext[param.name] = args.slice(i);
|
|
1096
|
+
} else {
|
|
1097
|
+
innerContext[param] = args[i];
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const innerEval = new Evaluator(innerContext);
|
|
1102
|
+
return innerEval.evaluate(node.body);
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
evaluateArray(node) {
|
|
1107
|
+
const result = [];
|
|
1108
|
+
for (const el of node.elements) {
|
|
1109
|
+
if (el.type === 'Spread') {
|
|
1110
|
+
const val = this.evaluate(el.argument);
|
|
1111
|
+
if (Array.isArray(val)) {
|
|
1112
|
+
result.push(...val);
|
|
1113
|
+
}
|
|
1114
|
+
} else {
|
|
1115
|
+
result.push(this.evaluate(el));
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return result;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
evaluateObject(node) {
|
|
1122
|
+
const result = {};
|
|
1123
|
+
for (const prop of node.properties) {
|
|
1124
|
+
if (prop.type === 'SpreadProperty') {
|
|
1125
|
+
const val = this.evaluate(prop.argument);
|
|
1126
|
+
if (typeof val === 'object' && val !== null) {
|
|
1127
|
+
Object.assign(result, val);
|
|
1128
|
+
}
|
|
1129
|
+
} else {
|
|
1130
|
+
const key = prop.computed ? this.evaluate(prop.key) : prop.key;
|
|
1131
|
+
result[key] = this.evaluate(prop.value);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return result;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// ============================================================
|
|
1139
|
+
// Expression Cache (LRU)
|
|
1140
|
+
// ============================================================
|
|
1141
|
+
|
|
1142
|
+
class ExpressionCache {
|
|
1143
|
+
constructor(maxSize = 500) {
|
|
1144
|
+
this.maxSize = maxSize;
|
|
1145
|
+
this.cache = new Map();
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
get(key) {
|
|
1149
|
+
if (!this.cache.has(key)) return null;
|
|
1150
|
+
// Move to end (most recently used)
|
|
1151
|
+
const value = this.cache.get(key);
|
|
1152
|
+
this.cache.delete(key);
|
|
1153
|
+
this.cache.set(key, value);
|
|
1154
|
+
return value;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
set(key, value) {
|
|
1158
|
+
if (this.cache.has(key)) {
|
|
1159
|
+
this.cache.delete(key);
|
|
1160
|
+
} else if (this.cache.size >= this.maxSize) {
|
|
1161
|
+
// Delete oldest entry
|
|
1162
|
+
const firstKey = this.cache.keys().next().value;
|
|
1163
|
+
this.cache.delete(firstKey);
|
|
1164
|
+
}
|
|
1165
|
+
this.cache.set(key, value);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
clear() {
|
|
1169
|
+
this.cache.clear();
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// ============================================================
|
|
1174
|
+
// Public API
|
|
1175
|
+
// ============================================================
|
|
1176
|
+
|
|
1177
|
+
class ExpressionEvaluator {
|
|
1178
|
+
constructor() {
|
|
1179
|
+
this.cache = new ExpressionCache(500);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Parse and evaluate an expression
|
|
1184
|
+
* @param {string} expression - Expression string
|
|
1185
|
+
* @param {Object} context - Variable context
|
|
1186
|
+
* @returns {*} Result
|
|
1187
|
+
*/
|
|
1188
|
+
evaluate(expression, context = {}) {
|
|
1189
|
+
if (!expression || typeof expression !== 'string') return undefined;
|
|
1190
|
+
expression = expression.trim();
|
|
1191
|
+
if (expression === '') return undefined;
|
|
1192
|
+
|
|
1193
|
+
try {
|
|
1194
|
+
// Get or parse AST (cached)
|
|
1195
|
+
let ast = this.cache.get(expression);
|
|
1196
|
+
if (!ast) {
|
|
1197
|
+
const lexer = new Lexer(expression);
|
|
1198
|
+
const parser = new Parser(lexer);
|
|
1199
|
+
ast = parser.parse();
|
|
1200
|
+
this.cache.set(expression, ast);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Evaluate with context
|
|
1204
|
+
const evaluator = new Evaluator(context);
|
|
1205
|
+
return evaluator.evaluate(ast);
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
// Silent fail in production — return undefined
|
|
1208
|
+
return undefined;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Evaluate and return boolean
|
|
1214
|
+
* @param {string} expression
|
|
1215
|
+
* @param {Object} context
|
|
1216
|
+
* @returns {boolean}
|
|
1217
|
+
*/
|
|
1218
|
+
isTruthy(expression, context = {}) {
|
|
1219
|
+
return Boolean(this.evaluate(expression, context));
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Clear the AST cache
|
|
1224
|
+
*/
|
|
1225
|
+
clearCache() {
|
|
1226
|
+
this.cache.clear();
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Singleton instance
|
|
1231
|
+
const expressionEvaluator = new ExpressionEvaluator();
|
|
1232
|
+
|
|
1233
|
+
export { ExpressionEvaluator, ExpressionCache, Lexer, Parser, Evaluator };
|
|
1234
|
+
export default expressionEvaluator;
|