zero-query 1.1.1 → 1.2.0

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 (154) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -0
  3. package/cli/args.js +33 -33
  4. package/cli/commands/build-api.js +443 -442
  5. package/cli/commands/build.js +254 -247
  6. package/cli/commands/bundle.js +1228 -1224
  7. package/cli/commands/create.js +137 -121
  8. package/cli/commands/dev/devtools/index.js +56 -56
  9. package/cli/commands/dev/devtools/js/components.js +49 -49
  10. package/cli/commands/dev/devtools/js/core.js +423 -423
  11. package/cli/commands/dev/devtools/js/elements.js +421 -421
  12. package/cli/commands/dev/devtools/js/network.js +166 -166
  13. package/cli/commands/dev/devtools/js/performance.js +73 -73
  14. package/cli/commands/dev/devtools/js/router.js +105 -105
  15. package/cli/commands/dev/devtools/js/source.js +132 -132
  16. package/cli/commands/dev/devtools/js/stats.js +35 -35
  17. package/cli/commands/dev/devtools/js/tabs.js +79 -79
  18. package/cli/commands/dev/devtools/panel.html +95 -95
  19. package/cli/commands/dev/devtools/styles.css +244 -244
  20. package/cli/commands/dev/index.js +107 -107
  21. package/cli/commands/dev/logger.js +75 -75
  22. package/cli/commands/dev/overlay.js +858 -858
  23. package/cli/commands/dev/server.js +220 -220
  24. package/cli/commands/dev/validator.js +94 -94
  25. package/cli/commands/dev/watcher.js +172 -172
  26. package/cli/help.js +114 -112
  27. package/cli/index.js +52 -52
  28. package/cli/scaffold/default/LICENSE +21 -21
  29. package/cli/scaffold/default/app/app.js +207 -207
  30. package/cli/scaffold/default/app/components/about.js +201 -201
  31. package/cli/scaffold/default/app/components/api-demo.js +143 -143
  32. package/cli/scaffold/default/app/components/contact-card.js +231 -231
  33. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
  34. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
  35. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
  36. package/cli/scaffold/default/app/components/counter.js +127 -127
  37. package/cli/scaffold/default/app/components/home.js +249 -249
  38. package/cli/scaffold/default/app/components/not-found.js +16 -16
  39. package/cli/scaffold/default/app/components/playground/playground.css +115 -115
  40. package/cli/scaffold/default/app/components/playground/playground.html +161 -161
  41. package/cli/scaffold/default/app/components/playground/playground.js +116 -116
  42. package/cli/scaffold/default/app/components/todos.js +225 -225
  43. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
  44. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
  45. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
  46. package/cli/scaffold/default/app/routes.js +15 -15
  47. package/cli/scaffold/default/app/store.js +101 -101
  48. package/cli/scaffold/default/global.css +552 -552
  49. package/cli/scaffold/default/index.html +99 -99
  50. package/cli/scaffold/minimal/app/app.js +85 -85
  51. package/cli/scaffold/minimal/app/components/about.js +68 -68
  52. package/cli/scaffold/minimal/app/components/counter.js +122 -122
  53. package/cli/scaffold/minimal/app/components/home.js +68 -68
  54. package/cli/scaffold/minimal/app/components/not-found.js +16 -16
  55. package/cli/scaffold/minimal/app/routes.js +9 -9
  56. package/cli/scaffold/minimal/app/store.js +36 -36
  57. package/cli/scaffold/minimal/global.css +300 -300
  58. package/cli/scaffold/minimal/index.html +44 -44
  59. package/cli/scaffold/ssr/app/app.js +41 -41
  60. package/cli/scaffold/ssr/app/components/about.js +55 -55
  61. package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
  62. package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
  63. package/cli/scaffold/ssr/app/components/home.js +37 -37
  64. package/cli/scaffold/ssr/app/components/not-found.js +15 -15
  65. package/cli/scaffold/ssr/app/routes.js +8 -8
  66. package/cli/scaffold/ssr/global.css +228 -228
  67. package/cli/scaffold/ssr/index.html +37 -37
  68. package/cli/scaffold/ssr/package.json +8 -8
  69. package/cli/scaffold/ssr/server/data/posts.js +144 -144
  70. package/cli/scaffold/ssr/server/index.js +213 -213
  71. package/cli/scaffold/webrtc/app/app.js +11 -0
  72. package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
  73. package/cli/scaffold/webrtc/app/lib/room.js +252 -0
  74. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  75. package/cli/scaffold/webrtc/global.css +250 -0
  76. package/cli/scaffold/webrtc/index.html +21 -0
  77. package/cli/utils.js +305 -287
  78. package/dist/API.md +661 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6614
  81. package/dist/zquery.min.js +8 -631
  82. package/index.d.ts +570 -371
  83. package/index.js +311 -240
  84. package/package.json +76 -70
  85. package/src/component.js +1709 -1691
  86. package/src/core.js +921 -921
  87. package/src/diff.js +497 -497
  88. package/src/errors.js +209 -209
  89. package/src/expression.js +922 -922
  90. package/src/http.js +242 -242
  91. package/src/package.json +1 -1
  92. package/src/reactive.js +255 -255
  93. package/src/router.js +843 -843
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -318
  96. package/src/utils.js +515 -515
  97. package/src/webrtc/e2ee.js +351 -0
  98. package/src/webrtc/errors.js +116 -0
  99. package/src/webrtc/ice.js +301 -0
  100. package/src/webrtc/index.js +131 -0
  101. package/src/webrtc/joinToken.js +119 -0
  102. package/src/webrtc/observe.js +172 -0
  103. package/src/webrtc/peer.js +351 -0
  104. package/src/webrtc/reactive.js +268 -0
  105. package/src/webrtc/room.js +625 -0
  106. package/src/webrtc/sdp.js +302 -0
  107. package/src/webrtc/sfu/index.js +43 -0
  108. package/src/webrtc/sfu/livekit.js +131 -0
  109. package/src/webrtc/sfu/mediasoup.js +150 -0
  110. package/src/webrtc/signaling.js +373 -0
  111. package/src/webrtc/turn.js +237 -0
  112. package/tests/_helpers/webrtcFakes.js +289 -0
  113. package/tests/audit.test.js +4158 -4158
  114. package/tests/cli.test.js +1136 -1103
  115. package/tests/compare.test.js +497 -486
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -489
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -1650
  121. package/tests/electron-features.test.js +864 -864
  122. package/tests/errors.test.js +619 -619
  123. package/tests/expression.test.js +1056 -1056
  124. package/tests/http.test.js +648 -648
  125. package/tests/reactive.test.js +819 -819
  126. package/tests/router.test.js +2327 -2327
  127. package/tests/ssr.test.js +870 -870
  128. package/tests/store.test.js +830 -830
  129. package/tests/test-minifier.js +153 -153
  130. package/tests/test-ssr.js +27 -27
  131. package/tests/utils.test.js +1377 -1377
  132. package/tests/webrtc/e2ee.test.js +283 -0
  133. package/tests/webrtc/ice.test.js +202 -0
  134. package/tests/webrtc/joinToken.test.js +89 -0
  135. package/tests/webrtc/observe.test.js +111 -0
  136. package/tests/webrtc/peer.test.js +373 -0
  137. package/tests/webrtc/reactive.test.js +235 -0
  138. package/tests/webrtc/room.test.js +406 -0
  139. package/tests/webrtc/sdp.test.js +151 -0
  140. package/tests/webrtc/sfu-livekit.test.js +119 -0
  141. package/tests/webrtc/sfu.test.js +160 -0
  142. package/tests/webrtc/signaling.test.js +251 -0
  143. package/tests/webrtc/turn.test.js +256 -0
  144. package/types/collection.d.ts +383 -383
  145. package/types/component.d.ts +186 -186
  146. package/types/errors.d.ts +135 -135
  147. package/types/http.d.ts +92 -92
  148. package/types/misc.d.ts +201 -201
  149. package/types/reactive.d.ts +98 -98
  150. package/types/router.d.ts +190 -190
  151. package/types/ssr.d.ts +102 -102
  152. package/types/store.d.ts +146 -146
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
package/src/expression.js CHANGED
@@ -1,922 +1,922 @@
1
- /**
2
- * zQuery Expression Parser - CSP-safe expression evaluator
3
- *
4
- * Replaces `new Function()` / `eval()` with a hand-written parser that
5
- * evaluates expressions safely without violating Content Security Policy.
6
- *
7
- * Supports:
8
- * - Property access: user.name, items[0], items[i]
9
- * - Method calls: items.length, str.toUpperCase()
10
- * - Arithmetic: a + b, count * 2, i % 2
11
- * - Comparison: a === b, count > 0, x != null
12
- * - Logical: a && b, a || b, !a
13
- * - Ternary: a ? b : c
14
- * - Typeof: typeof x
15
- * - Unary: -a, +a, !a
16
- * - Literals: 42, 'hello', "world", true, false, null, undefined
17
- * - Template literals: `Hello ${name}`
18
- * - Array literals: [1, 2, 3]
19
- * - Object literals: { foo: 'bar', baz: 1 }
20
- * - Grouping: (a + b) * c
21
- * - Nullish coalescing: a ?? b
22
- * - Optional chaining: a?.b, a?.[b], a?.()
23
- * - Arrow functions: x => x.id, (a, b) => a + b
24
- */
25
-
26
- // Token types
27
- const T = {
28
- NUM: 1, STR: 2, IDENT: 3, OP: 4, PUNC: 5, TMPL: 6, EOF: 7
29
- };
30
-
31
- // Operator precedence (higher = binds tighter)
32
- const PREC = {
33
- '??': 2,
34
- '||': 3,
35
- '&&': 4,
36
- '==': 8, '!=': 8, '===': 8, '!==': 8,
37
- '<': 9, '>': 9, '<=': 9, '>=': 9, 'instanceof': 9, 'in': 9,
38
- '+': 11, '-': 11,
39
- '*': 12, '/': 12, '%': 12,
40
- };
41
-
42
- const KEYWORDS = new Set([
43
- 'true', 'false', 'null', 'undefined', 'typeof', 'instanceof', 'in',
44
- 'new', 'void'
45
- ]);
46
-
47
- // ---------------------------------------------------------------------------
48
- // Tokenizer
49
- // ---------------------------------------------------------------------------
50
- function tokenize(expr) {
51
- const tokens = [];
52
- let i = 0;
53
- const len = expr.length;
54
-
55
- while (i < len) {
56
- const ch = expr[i];
57
-
58
- // Whitespace
59
- if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { i++; continue; }
60
-
61
- // Numbers
62
- if ((ch >= '0' && ch <= '9') || (ch === '.' && i + 1 < len && expr[i + 1] >= '0' && expr[i + 1] <= '9')) {
63
- let num = '';
64
- if (ch === '0' && i + 1 < len && (expr[i + 1] === 'x' || expr[i + 1] === 'X')) {
65
- num = '0x'; i += 2;
66
- while (i < len && /[0-9a-fA-F]/.test(expr[i])) num += expr[i++];
67
- } else {
68
- while (i < len && ((expr[i] >= '0' && expr[i] <= '9') || expr[i] === '.')) num += expr[i++];
69
- if (i < len && (expr[i] === 'e' || expr[i] === 'E')) {
70
- num += expr[i++];
71
- if (i < len && (expr[i] === '+' || expr[i] === '-')) num += expr[i++];
72
- while (i < len && expr[i] >= '0' && expr[i] <= '9') num += expr[i++];
73
- }
74
- }
75
- tokens.push({ t: T.NUM, v: Number(num) });
76
- continue;
77
- }
78
-
79
- // Strings
80
- if (ch === "'" || ch === '"') {
81
- const quote = ch;
82
- let str = '';
83
- i++;
84
- while (i < len && expr[i] !== quote) {
85
- if (expr[i] === '\\' && i + 1 < len) {
86
- const esc = expr[++i];
87
- if (esc === 'n') str += '\n';
88
- else if (esc === 't') str += '\t';
89
- else if (esc === 'r') str += '\r';
90
- else if (esc === '\\') str += '\\';
91
- else if (esc === quote) str += quote;
92
- else str += esc;
93
- } else {
94
- str += expr[i];
95
- }
96
- i++;
97
- }
98
- i++; // closing quote
99
- tokens.push({ t: T.STR, v: str });
100
- continue;
101
- }
102
-
103
- // Template literals
104
- if (ch === '`') {
105
- const parts = []; // alternating: string, expr, string, expr, ...
106
- let str = '';
107
- i++;
108
- while (i < len && expr[i] !== '`') {
109
- if (expr[i] === '$' && i + 1 < len && expr[i + 1] === '{') {
110
- parts.push(str);
111
- str = '';
112
- i += 2;
113
- let depth = 1;
114
- let inner = '';
115
- while (i < len && depth > 0) {
116
- if (expr[i] === '{') depth++;
117
- else if (expr[i] === '}') { depth--; if (depth === 0) break; }
118
- inner += expr[i++];
119
- }
120
- i++; // closing }
121
- parts.push({ expr: inner });
122
- } else {
123
- if (expr[i] === '\\' && i + 1 < len) { str += expr[++i]; }
124
- else str += expr[i];
125
- i++;
126
- }
127
- }
128
- i++; // closing backtick
129
- parts.push(str);
130
- tokens.push({ t: T.TMPL, v: parts });
131
- continue;
132
- }
133
-
134
- // Identifiers & keywords
135
- if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_' || ch === '$') {
136
- let ident = '';
137
- while (i < len && /[\w$]/.test(expr[i])) ident += expr[i++];
138
- tokens.push({ t: T.IDENT, v: ident });
139
- continue;
140
- }
141
-
142
- // Multi-char operators
143
- const two = expr.slice(i, i + 3);
144
- if (two === '===' || two === '!==' || two === '?.') {
145
- if (two === '?.') {
146
- tokens.push({ t: T.OP, v: '?.' });
147
- i += 2;
148
- } else {
149
- tokens.push({ t: T.OP, v: two });
150
- i += 3;
151
- }
152
- continue;
153
- }
154
- const pair = expr.slice(i, i + 2);
155
- if (pair === '==' || pair === '!=' || pair === '<=' || pair === '>=' ||
156
- pair === '&&' || pair === '||' || pair === '??' || pair === '?.' ||
157
- pair === '=>') {
158
- tokens.push({ t: T.OP, v: pair });
159
- i += 2;
160
- continue;
161
- }
162
-
163
- // Single char operators and punctuation
164
- if ('+-*/%'.includes(ch)) {
165
- tokens.push({ t: T.OP, v: ch });
166
- i++; continue;
167
- }
168
- if ('<>=!'.includes(ch)) {
169
- tokens.push({ t: T.OP, v: ch });
170
- i++; continue;
171
- }
172
- // Spread operator: ...
173
- if (ch === '.' && i + 2 < len && expr[i + 1] === '.' && expr[i + 2] === '.') {
174
- tokens.push({ t: T.OP, v: '...' });
175
- i += 3; continue;
176
- }
177
- if ('()[]{},.?:'.includes(ch)) {
178
- tokens.push({ t: T.PUNC, v: ch });
179
- i++; continue;
180
- }
181
-
182
- // Unknown - skip
183
- i++;
184
- }
185
-
186
- tokens.push({ t: T.EOF, v: null });
187
- return tokens;
188
- }
189
-
190
- // ---------------------------------------------------------------------------
191
- // Parser - Pratt (precedence climbing)
192
- // ---------------------------------------------------------------------------
193
- class Parser {
194
- constructor(tokens, scope) {
195
- this.tokens = tokens;
196
- this.pos = 0;
197
- this.scope = scope;
198
- }
199
-
200
- peek() { return this.tokens[this.pos]; }
201
- next() { return this.tokens[this.pos++]; }
202
-
203
- expect(type, val) {
204
- const t = this.next();
205
- if (t.t !== type || (val !== undefined && t.v !== val)) {
206
- throw new Error(`Expected ${val || type} but got ${t.v}`);
207
- }
208
- return t;
209
- }
210
-
211
- match(type, val) {
212
- const t = this.peek();
213
- if (t.t === type && (val === undefined || t.v === val)) {
214
- return this.next();
215
- }
216
- return null;
217
- }
218
-
219
- // Main entry
220
- parse() {
221
- const result = this.parseExpression(0);
222
- return result;
223
- }
224
-
225
- // Precedence climbing
226
- parseExpression(minPrec) {
227
- let left = this.parseUnary();
228
-
229
- while (true) {
230
- const tok = this.peek();
231
-
232
- // Ternary
233
- if (tok.t === T.PUNC && tok.v === '?') {
234
- // Distinguish ternary ? from optional chaining ?.
235
- if (this.tokens[this.pos + 1]?.v !== '.') {
236
- if (1 <= minPrec) break; // ternary has very low precedence
237
- this.next(); // consume ?
238
- const truthy = this.parseExpression(0);
239
- this.expect(T.PUNC, ':');
240
- const falsy = this.parseExpression(0);
241
- left = { type: 'ternary', cond: left, truthy, falsy };
242
- continue;
243
- }
244
- }
245
-
246
- // Binary operators
247
- if (tok.t === T.OP && tok.v in PREC) {
248
- const prec = PREC[tok.v];
249
- if (prec <= minPrec) break;
250
- this.next();
251
- const right = this.parseExpression(prec);
252
- left = { type: 'binary', op: tok.v, left, right };
253
- continue;
254
- }
255
-
256
- // instanceof and in as binary operators
257
- if (tok.t === T.IDENT && (tok.v === 'instanceof' || tok.v === 'in') && PREC[tok.v] > minPrec) {
258
- const prec = PREC[tok.v];
259
- this.next();
260
- const right = this.parseExpression(prec);
261
- left = { type: 'binary', op: tok.v, left, right };
262
- continue;
263
- }
264
-
265
- break;
266
- }
267
-
268
- return left;
269
- }
270
-
271
- parseUnary() {
272
- const tok = this.peek();
273
-
274
- // typeof
275
- if (tok.t === T.IDENT && tok.v === 'typeof') {
276
- this.next();
277
- const arg = this.parseUnary();
278
- return { type: 'typeof', arg };
279
- }
280
-
281
- // void
282
- if (tok.t === T.IDENT && tok.v === 'void') {
283
- this.next();
284
- this.parseUnary(); // evaluate but discard
285
- return { type: 'literal', value: undefined };
286
- }
287
-
288
- // !expr
289
- if (tok.t === T.OP && tok.v === '!') {
290
- this.next();
291
- const arg = this.parseUnary();
292
- return { type: 'not', arg };
293
- }
294
-
295
- // -expr, +expr
296
- if (tok.t === T.OP && (tok.v === '-' || tok.v === '+')) {
297
- this.next();
298
- const arg = this.parseUnary();
299
- return { type: 'unary', op: tok.v, arg };
300
- }
301
-
302
- return this.parsePostfix();
303
- }
304
-
305
- parsePostfix() {
306
- let left = this.parsePrimary();
307
-
308
- while (true) {
309
- const tok = this.peek();
310
-
311
- // Property access: a.b
312
- if (tok.t === T.PUNC && tok.v === '.') {
313
- this.next();
314
- const prop = this.next();
315
- left = { type: 'member', obj: left, prop: prop.v, computed: false };
316
- // Check for method call: a.b()
317
- if (this.peek().t === T.PUNC && this.peek().v === '(') {
318
- left = this._parseCall(left);
319
- }
320
- continue;
321
- }
322
-
323
- // Optional chaining: a?.b, a?.[b], a?.()
324
- if (tok.t === T.OP && tok.v === '?.') {
325
- this.next();
326
- const next = this.peek();
327
- if (next.t === T.PUNC && next.v === '[') {
328
- // a?.[expr]
329
- this.next();
330
- const prop = this.parseExpression(0);
331
- this.expect(T.PUNC, ']');
332
- left = { type: 'optional_member', obj: left, prop, computed: true };
333
- } else if (next.t === T.PUNC && next.v === '(') {
334
- // a?.()
335
- left = { type: 'optional_call', callee: left, args: this._parseArgs() };
336
- } else {
337
- // a?.b
338
- const prop = this.next();
339
- left = { type: 'optional_member', obj: left, prop: prop.v, computed: false };
340
- if (this.peek().t === T.PUNC && this.peek().v === '(') {
341
- left = this._parseCall(left);
342
- }
343
- }
344
- continue;
345
- }
346
-
347
- // Computed access: a[b]
348
- if (tok.t === T.PUNC && tok.v === '[') {
349
- this.next();
350
- const prop = this.parseExpression(0);
351
- this.expect(T.PUNC, ']');
352
- left = { type: 'member', obj: left, prop, computed: true };
353
- // Check for method call: a[b]()
354
- if (this.peek().t === T.PUNC && this.peek().v === '(') {
355
- left = this._parseCall(left);
356
- }
357
- continue;
358
- }
359
-
360
- // Function call: fn()
361
- if (tok.t === T.PUNC && tok.v === '(') {
362
- left = this._parseCall(left);
363
- continue;
364
- }
365
-
366
- break;
367
- }
368
-
369
- return left;
370
- }
371
-
372
- _parseCall(callee) {
373
- const args = this._parseArgs();
374
- return { type: 'call', callee, args };
375
- }
376
-
377
- _parseArgs() {
378
- this.expect(T.PUNC, '(');
379
- const args = [];
380
- while (!(this.peek().t === T.PUNC && this.peek().v === ')') && this.peek().t !== T.EOF) {
381
- if (this.peek().t === T.OP && this.peek().v === '...') {
382
- this.next();
383
- args.push({ type: 'spread', arg: this.parseExpression(0) });
384
- } else {
385
- args.push(this.parseExpression(0));
386
- }
387
- if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
388
- }
389
- this.expect(T.PUNC, ')');
390
- return args;
391
- }
392
-
393
- parsePrimary() {
394
- const tok = this.peek();
395
-
396
- // Number literal
397
- if (tok.t === T.NUM) {
398
- this.next();
399
- return { type: 'literal', value: tok.v };
400
- }
401
-
402
- // String literal
403
- if (tok.t === T.STR) {
404
- this.next();
405
- return { type: 'literal', value: tok.v };
406
- }
407
-
408
- // Template literal
409
- if (tok.t === T.TMPL) {
410
- this.next();
411
- return { type: 'template', parts: tok.v };
412
- }
413
-
414
- // Arrow function with parens: () =>, (a) =>, (a, b) =>
415
- // or regular grouping: (expr)
416
- if (tok.t === T.PUNC && tok.v === '(') {
417
- const savedPos = this.pos;
418
- this.next(); // consume (
419
- const params = [];
420
- let couldBeArrow = true;
421
-
422
- if (this.peek().t === T.PUNC && this.peek().v === ')') {
423
- // () => ... - no params
424
- } else {
425
- while (couldBeArrow) {
426
- const p = this.peek();
427
- if (p.t === T.IDENT && !KEYWORDS.has(p.v)) {
428
- params.push(this.next().v);
429
- if (this.peek().t === T.PUNC && this.peek().v === ',') {
430
- this.next();
431
- } else {
432
- break;
433
- }
434
- } else {
435
- couldBeArrow = false;
436
- }
437
- }
438
- }
439
-
440
- if (couldBeArrow && this.peek().t === T.PUNC && this.peek().v === ')') {
441
- this.next(); // consume )
442
- if (this.peek().t === T.OP && this.peek().v === '=>') {
443
- this.next(); // consume =>
444
- const body = this.parseExpression(0);
445
- return { type: 'arrow', params, body };
446
- }
447
- }
448
-
449
- // Not an arrow - restore and parse as grouping
450
- this.pos = savedPos;
451
- this.next(); // consume (
452
- const expr = this.parseExpression(0);
453
- this.expect(T.PUNC, ')');
454
- return expr;
455
- }
456
-
457
- // Array literal
458
- if (tok.t === T.PUNC && tok.v === '[') {
459
- this.next();
460
- const elements = [];
461
- while (!(this.peek().t === T.PUNC && this.peek().v === ']') && this.peek().t !== T.EOF) {
462
- if (this.peek().t === T.OP && this.peek().v === '...') {
463
- this.next();
464
- elements.push({ type: 'spread', arg: this.parseExpression(0) });
465
- } else {
466
- elements.push(this.parseExpression(0));
467
- }
468
- if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
469
- }
470
- this.expect(T.PUNC, ']');
471
- return { type: 'array', elements };
472
- }
473
-
474
- // Object literal
475
- if (tok.t === T.PUNC && tok.v === '{') {
476
- this.next();
477
- const properties = [];
478
- while (!(this.peek().t === T.PUNC && this.peek().v === '}') && this.peek().t !== T.EOF) {
479
- // Spread in object: { ...obj }
480
- if (this.peek().t === T.OP && this.peek().v === '...') {
481
- this.next();
482
- properties.push({ spread: true, value: this.parseExpression(0) });
483
- if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
484
- continue;
485
- }
486
-
487
- const keyTok = this.next();
488
- let key;
489
- if (keyTok.t === T.IDENT || keyTok.t === T.STR) key = keyTok.v;
490
- else if (keyTok.t === T.NUM) key = String(keyTok.v);
491
- else throw new Error('Invalid object key: ' + keyTok.v);
492
-
493
- // Shorthand property: { foo } means { foo: foo }
494
- if (this.peek().t === T.PUNC && (this.peek().v === ',' || this.peek().v === '}')) {
495
- properties.push({ key, value: { type: 'ident', name: key } });
496
- } else {
497
- this.expect(T.PUNC, ':');
498
- properties.push({ key, value: this.parseExpression(0) });
499
- }
500
- if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
501
- }
502
- this.expect(T.PUNC, '}');
503
- return { type: 'object', properties };
504
- }
505
-
506
- // Identifiers & keywords
507
- if (tok.t === T.IDENT) {
508
- this.next();
509
-
510
- // Keywords
511
- if (tok.v === 'true') return { type: 'literal', value: true };
512
- if (tok.v === 'false') return { type: 'literal', value: false };
513
- if (tok.v === 'null') return { type: 'literal', value: null };
514
- if (tok.v === 'undefined') return { type: 'literal', value: undefined };
515
-
516
- // new keyword
517
- if (tok.v === 'new') {
518
- let classExpr = this.parsePrimary();
519
- // Handle member access (e.g. ns.MyClass) without consuming call args
520
- while (this.peek().t === T.PUNC && this.peek().v === '.') {
521
- this.next();
522
- const prop = this.next();
523
- classExpr = { type: 'member', obj: classExpr, prop: prop.v, computed: false };
524
- }
525
- let args = [];
526
- if (this.peek().t === T.PUNC && this.peek().v === '(') {
527
- args = this._parseArgs();
528
- }
529
- return { type: 'new', callee: classExpr, args };
530
- }
531
-
532
- // Arrow function: x => expr
533
- if (this.peek().t === T.OP && this.peek().v === '=>') {
534
- this.next(); // consume =>
535
- const body = this.parseExpression(0);
536
- return { type: 'arrow', params: [tok.v], body };
537
- }
538
-
539
- return { type: 'ident', name: tok.v };
540
- }
541
-
542
- // Fallback - return undefined for unparseable
543
- this.next();
544
- return { type: 'literal', value: undefined };
545
- }
546
- }
547
-
548
- // ---------------------------------------------------------------------------
549
- // Evaluator - walks the AST, resolves against scope
550
- // ---------------------------------------------------------------------------
551
-
552
- /** Safe property access whitelist for built-in prototypes */
553
- const SAFE_ARRAY_METHODS = new Set([
554
- 'length', 'map', 'filter', 'find', 'findIndex', 'some', 'every',
555
- 'reduce', 'reduceRight', 'forEach', 'includes', 'indexOf', 'lastIndexOf',
556
- 'join', 'slice', 'concat', 'flat', 'flatMap', 'reverse', 'sort',
557
- 'fill', 'keys', 'values', 'entries', 'at', 'toString',
558
- ]);
559
-
560
- const SAFE_STRING_METHODS = new Set([
561
- 'length', 'charAt', 'charCodeAt', 'includes', 'indexOf', 'lastIndexOf',
562
- 'slice', 'substring', 'trim', 'trimStart', 'trimEnd', 'toLowerCase',
563
- 'toUpperCase', 'split', 'replace', 'replaceAll', 'match', 'search',
564
- 'startsWith', 'endsWith', 'padStart', 'padEnd', 'repeat', 'at',
565
- 'toString', 'valueOf',
566
- ]);
567
-
568
- const SAFE_NUMBER_METHODS = new Set([
569
- 'toFixed', 'toPrecision', 'toString', 'valueOf',
570
- ]);
571
-
572
- const SAFE_OBJECT_METHODS = new Set([
573
- 'hasOwnProperty', 'toString', 'valueOf',
574
- ]);
575
-
576
- const SAFE_MATH_PROPS = new Set([
577
- 'PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2',
578
- 'abs', 'ceil', 'floor', 'round', 'trunc', 'max', 'min', 'pow',
579
- 'sqrt', 'sign', 'random', 'log', 'log2', 'log10',
580
- ]);
581
-
582
- const SAFE_JSON_PROPS = new Set(['parse', 'stringify']);
583
-
584
- /**
585
- * Check if property access is safe
586
- */
587
- function _isSafeAccess(obj, prop) {
588
- // Never allow access to dangerous properties
589
- const BLOCKED = new Set([
590
- 'constructor', '__proto__', 'prototype', '__defineGetter__',
591
- '__defineSetter__', '__lookupGetter__', '__lookupSetter__',
592
- 'call', 'apply', 'bind',
593
- ]);
594
- if (typeof prop === 'string' && BLOCKED.has(prop)) return false;
595
-
596
- // Always allow plain object/function property access and array index access
597
- if (obj !== null && obj !== undefined && (typeof obj === 'object' || typeof obj === 'function')) return true;
598
- if (typeof obj === 'string') return SAFE_STRING_METHODS.has(prop);
599
- if (typeof obj === 'number') return SAFE_NUMBER_METHODS.has(prop);
600
- return false;
601
- }
602
-
603
- function evaluate(node, scope) {
604
- if (!node) return undefined;
605
-
606
- switch (node.type) {
607
- case 'literal':
608
- return node.value;
609
-
610
- case 'ident': {
611
- const name = node.name;
612
- // Check scope layers in order
613
- for (const layer of scope) {
614
- if (layer && typeof layer === 'object' && name in layer) {
615
- return layer[name];
616
- }
617
- }
618
- // Built-in globals (safe ones only)
619
- if (name === 'Math') return Math;
620
- if (name === 'JSON') return JSON;
621
- if (name === 'Date') return Date;
622
- if (name === 'Array') return Array;
623
- if (name === 'Object') return Object;
624
- if (name === 'String') return String;
625
- if (name === 'Number') return Number;
626
- if (name === 'Boolean') return Boolean;
627
- if (name === 'parseInt') return parseInt;
628
- if (name === 'parseFloat') return parseFloat;
629
- if (name === 'isNaN') return isNaN;
630
- if (name === 'isFinite') return isFinite;
631
- if (name === 'Infinity') return Infinity;
632
- if (name === 'NaN') return NaN;
633
- if (name === 'encodeURIComponent') return encodeURIComponent;
634
- if (name === 'decodeURIComponent') return decodeURIComponent;
635
- if (name === 'console') return console;
636
- if (name === 'Map') return Map;
637
- if (name === 'Set') return Set;
638
- if (name === 'URL') return URL;
639
- if (name === 'URLSearchParams') return URLSearchParams;
640
- return undefined;
641
- }
642
-
643
- case 'template': {
644
- // Template literal with interpolation
645
- let result = '';
646
- for (const part of node.parts) {
647
- if (typeof part === 'string') {
648
- result += part;
649
- } else if (part && part.expr) {
650
- result += String(safeEval(part.expr, scope) ?? '');
651
- }
652
- }
653
- return result;
654
- }
655
-
656
- case 'member': {
657
- const obj = evaluate(node.obj, scope);
658
- if (obj == null) return undefined;
659
- const prop = node.computed ? evaluate(node.prop, scope) : node.prop;
660
- if (!_isSafeAccess(obj, prop)) return undefined;
661
- return obj[prop];
662
- }
663
-
664
- case 'optional_member': {
665
- const obj = evaluate(node.obj, scope);
666
- if (obj == null) return undefined;
667
- const prop = node.computed ? evaluate(node.prop, scope) : node.prop;
668
- if (!_isSafeAccess(obj, prop)) return undefined;
669
- return obj[prop];
670
- }
671
-
672
- case 'call': {
673
- const result = _resolveCall(node, scope, false);
674
- return result;
675
- }
676
-
677
- case 'optional_call': {
678
- const calleeNode = node.callee;
679
- const args = _evalArgs(node.args, scope);
680
- // Method call: obj?.method() - bind `this` to obj
681
- if (calleeNode.type === 'member' || calleeNode.type === 'optional_member') {
682
- const obj = evaluate(calleeNode.obj, scope);
683
- if (obj == null) return undefined;
684
- const prop = calleeNode.computed ? evaluate(calleeNode.prop, scope) : calleeNode.prop;
685
- if (!_isSafeAccess(obj, prop)) return undefined;
686
- const fn = obj[prop];
687
- if (typeof fn !== 'function') return undefined;
688
- return fn.apply(obj, args);
689
- }
690
- const callee = evaluate(calleeNode, scope);
691
- if (callee == null) return undefined;
692
- if (typeof callee !== 'function') return undefined;
693
- return callee(...args);
694
- }
695
-
696
- case 'new': {
697
- const Ctor = evaluate(node.callee, scope);
698
- if (typeof Ctor !== 'function') return undefined;
699
- // Only allow safe constructors (no RegExp - ReDoS risk, no Error - info leak)
700
- if (Ctor === Date || Ctor === Array || Ctor === Map || Ctor === Set ||
701
- Ctor === URL || Ctor === URLSearchParams) {
702
- const args = _evalArgs(node.args, scope);
703
- return new Ctor(...args);
704
- }
705
- return undefined;
706
- }
707
-
708
- case 'binary':
709
- return _evalBinary(node, scope);
710
-
711
- case 'unary': {
712
- const val = evaluate(node.arg, scope);
713
- return node.op === '-' ? -val : +val;
714
- }
715
-
716
- case 'not':
717
- return !evaluate(node.arg, scope);
718
-
719
- case 'typeof': {
720
- try {
721
- return typeof evaluate(node.arg, scope);
722
- } catch {
723
- return 'undefined';
724
- }
725
- }
726
-
727
- case 'ternary': {
728
- const cond = evaluate(node.cond, scope);
729
- return cond ? evaluate(node.truthy, scope) : evaluate(node.falsy, scope);
730
- }
731
-
732
- case 'array': {
733
- const arr = [];
734
- for (const e of node.elements) {
735
- if (e.type === 'spread') {
736
- const iterable = evaluate(e.arg, scope);
737
- if (iterable != null && typeof iterable[Symbol.iterator] === 'function') {
738
- for (const v of iterable) arr.push(v);
739
- }
740
- } else {
741
- arr.push(evaluate(e, scope));
742
- }
743
- }
744
- return arr;
745
- }
746
-
747
- case 'object': {
748
- const obj = {};
749
- for (const prop of node.properties) {
750
- if (prop.spread) {
751
- const source = evaluate(prop.value, scope);
752
- if (source != null && typeof source === 'object') {
753
- Object.assign(obj, source);
754
- }
755
- } else {
756
- obj[prop.key] = evaluate(prop.value, scope);
757
- }
758
- }
759
- return obj;
760
- }
761
-
762
- case 'arrow': {
763
- const paramNames = node.params;
764
- const bodyNode = node.body;
765
- const closedScope = scope;
766
- return function(...args) {
767
- const arrowScope = {};
768
- paramNames.forEach((name, i) => { arrowScope[name] = args[i]; });
769
- return evaluate(bodyNode, [arrowScope, ...closedScope]);
770
- };
771
- }
772
-
773
- default:
774
- return undefined;
775
- }
776
- }
777
-
778
- /**
779
- * Evaluate a list of argument AST nodes, flattening any spread elements.
780
- */
781
- function _evalArgs(argNodes, scope) {
782
- const result = [];
783
- for (const a of argNodes) {
784
- if (a.type === 'spread') {
785
- const iterable = evaluate(a.arg, scope);
786
- if (iterable != null && typeof iterable[Symbol.iterator] === 'function') {
787
- for (const v of iterable) result.push(v);
788
- }
789
- } else {
790
- result.push(evaluate(a, scope));
791
- }
792
- }
793
- return result;
794
- }
795
-
796
- /**
797
- * Resolve and execute a function call safely.
798
- */
799
- function _resolveCall(node, scope) {
800
- const callee = node.callee;
801
- const args = _evalArgs(node.args, scope);
802
-
803
- // Method call: obj.method() - bind `this` to obj
804
- if (callee.type === 'member' || callee.type === 'optional_member') {
805
- const obj = evaluate(callee.obj, scope);
806
- if (obj == null) return undefined;
807
- const prop = callee.computed ? evaluate(callee.prop, scope) : callee.prop;
808
- if (!_isSafeAccess(obj, prop)) return undefined;
809
- const fn = obj[prop];
810
- if (typeof fn !== 'function') return undefined;
811
- return fn.apply(obj, args);
812
- }
813
-
814
- // Direct call: fn(args)
815
- const fn = evaluate(callee, scope);
816
- if (typeof fn !== 'function') return undefined;
817
- return fn(...args);
818
- }
819
-
820
- /**
821
- * Evaluate binary expression.
822
- */
823
- function _evalBinary(node, scope) {
824
- // Short-circuit for logical ops
825
- if (node.op === '&&') {
826
- const left = evaluate(node.left, scope);
827
- return left ? evaluate(node.right, scope) : left;
828
- }
829
- if (node.op === '||') {
830
- const left = evaluate(node.left, scope);
831
- return left ? left : evaluate(node.right, scope);
832
- }
833
- if (node.op === '??') {
834
- const left = evaluate(node.left, scope);
835
- return left != null ? left : evaluate(node.right, scope);
836
- }
837
-
838
- const left = evaluate(node.left, scope);
839
- const right = evaluate(node.right, scope);
840
-
841
- switch (node.op) {
842
- case '+': return left + right;
843
- case '-': return left - right;
844
- case '*': return left * right;
845
- case '/': return left / right;
846
- case '%': return left % right;
847
- case '==': return left == right;
848
- case '!=': return left != right;
849
- case '===': return left === right;
850
- case '!==': return left !== right;
851
- case '<': return left < right;
852
- case '>': return left > right;
853
- case '<=': return left <= right;
854
- case '>=': return left >= right;
855
- case 'instanceof': return left instanceof right;
856
- case 'in': return left in right;
857
- default: return undefined;
858
- }
859
- }
860
-
861
-
862
- // ---------------------------------------------------------------------------
863
- // Public API
864
- // ---------------------------------------------------------------------------
865
-
866
- /**
867
- * Safely evaluate a JS expression string against scope layers.
868
- *
869
- * @param {string} expr - expression string
870
- * @param {object[]} scope - array of scope objects, checked in order
871
- * Typical: [loopVars, state, { props, refs, $ }]
872
- * @returns {*} - evaluation result, or undefined on error
873
- */
874
-
875
- // AST cache (LRU) - avoids re-tokenizing and re-parsing the same expression.
876
- // Uses Map insertion-order: on hit, delete + re-set moves entry to the end.
877
- // Eviction removes the least-recently-used (first) entry when at capacity.
878
- const _astCache = new Map();
879
- const _AST_CACHE_MAX = 512;
880
-
881
- export function safeEval(expr, scope) {
882
- try {
883
- const trimmed = expr.trim();
884
- if (!trimmed) return undefined;
885
-
886
- // Fast path for simple identifiers: "count", "name", "visible"
887
- // Avoids full tokenize→parse→evaluate overhead for the most common case.
888
- if (/^[a-zA-Z_$][\w$]*$/.test(trimmed)) {
889
- for (const layer of scope) {
890
- if (layer && typeof layer === 'object' && trimmed in layer) {
891
- return layer[trimmed];
892
- }
893
- }
894
- // Fall through to full parser for built-in globals (Math, JSON, etc.)
895
- }
896
-
897
- // Check AST cache (LRU: move to end on hit)
898
- let ast = _astCache.get(trimmed);
899
- if (ast) {
900
- _astCache.delete(trimmed);
901
- _astCache.set(trimmed, ast);
902
- } else {
903
- const tokens = tokenize(trimmed);
904
- const parser = new Parser(tokens, scope);
905
- ast = parser.parse();
906
-
907
- // Evict oldest entries when cache is full
908
- if (_astCache.size >= _AST_CACHE_MAX) {
909
- const first = _astCache.keys().next().value;
910
- _astCache.delete(first);
911
- }
912
- _astCache.set(trimmed, ast);
913
- }
914
-
915
- return evaluate(ast, scope);
916
- } catch (err) {
917
- if (typeof console !== 'undefined' && console.debug) {
918
- console.debug(`[zQuery EXPR_EVAL] Failed to evaluate: "${expr}"`, err.message);
919
- }
920
- return undefined;
921
- }
922
- }
1
+ /**
2
+ * zQuery Expression Parser - CSP-safe expression evaluator
3
+ *
4
+ * Replaces `new Function()` / `eval()` with a hand-written parser that
5
+ * evaluates expressions safely without violating Content Security Policy.
6
+ *
7
+ * Supports:
8
+ * - Property access: user.name, items[0], items[i]
9
+ * - Method calls: items.length, str.toUpperCase()
10
+ * - Arithmetic: a + b, count * 2, i % 2
11
+ * - Comparison: a === b, count > 0, x != null
12
+ * - Logical: a && b, a || b, !a
13
+ * - Ternary: a ? b : c
14
+ * - Typeof: typeof x
15
+ * - Unary: -a, +a, !a
16
+ * - Literals: 42, 'hello', "world", true, false, null, undefined
17
+ * - Template literals: `Hello ${name}`
18
+ * - Array literals: [1, 2, 3]
19
+ * - Object literals: { foo: 'bar', baz: 1 }
20
+ * - Grouping: (a + b) * c
21
+ * - Nullish coalescing: a ?? b
22
+ * - Optional chaining: a?.b, a?.[b], a?.()
23
+ * - Arrow functions: x => x.id, (a, b) => a + b
24
+ */
25
+
26
+ // Token types
27
+ const T = {
28
+ NUM: 1, STR: 2, IDENT: 3, OP: 4, PUNC: 5, TMPL: 6, EOF: 7
29
+ };
30
+
31
+ // Operator precedence (higher = binds tighter)
32
+ const PREC = {
33
+ '??': 2,
34
+ '||': 3,
35
+ '&&': 4,
36
+ '==': 8, '!=': 8, '===': 8, '!==': 8,
37
+ '<': 9, '>': 9, '<=': 9, '>=': 9, 'instanceof': 9, 'in': 9,
38
+ '+': 11, '-': 11,
39
+ '*': 12, '/': 12, '%': 12,
40
+ };
41
+
42
+ const KEYWORDS = new Set([
43
+ 'true', 'false', 'null', 'undefined', 'typeof', 'instanceof', 'in',
44
+ 'new', 'void'
45
+ ]);
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Tokenizer
49
+ // ---------------------------------------------------------------------------
50
+ function tokenize(expr) {
51
+ const tokens = [];
52
+ let i = 0;
53
+ const len = expr.length;
54
+
55
+ while (i < len) {
56
+ const ch = expr[i];
57
+
58
+ // Whitespace
59
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { i++; continue; }
60
+
61
+ // Numbers
62
+ if ((ch >= '0' && ch <= '9') || (ch === '.' && i + 1 < len && expr[i + 1] >= '0' && expr[i + 1] <= '9')) {
63
+ let num = '';
64
+ if (ch === '0' && i + 1 < len && (expr[i + 1] === 'x' || expr[i + 1] === 'X')) {
65
+ num = '0x'; i += 2;
66
+ while (i < len && /[0-9a-fA-F]/.test(expr[i])) num += expr[i++];
67
+ } else {
68
+ while (i < len && ((expr[i] >= '0' && expr[i] <= '9') || expr[i] === '.')) num += expr[i++];
69
+ if (i < len && (expr[i] === 'e' || expr[i] === 'E')) {
70
+ num += expr[i++];
71
+ if (i < len && (expr[i] === '+' || expr[i] === '-')) num += expr[i++];
72
+ while (i < len && expr[i] >= '0' && expr[i] <= '9') num += expr[i++];
73
+ }
74
+ }
75
+ tokens.push({ t: T.NUM, v: Number(num) });
76
+ continue;
77
+ }
78
+
79
+ // Strings
80
+ if (ch === "'" || ch === '"') {
81
+ const quote = ch;
82
+ let str = '';
83
+ i++;
84
+ while (i < len && expr[i] !== quote) {
85
+ if (expr[i] === '\\' && i + 1 < len) {
86
+ const esc = expr[++i];
87
+ if (esc === 'n') str += '\n';
88
+ else if (esc === 't') str += '\t';
89
+ else if (esc === 'r') str += '\r';
90
+ else if (esc === '\\') str += '\\';
91
+ else if (esc === quote) str += quote;
92
+ else str += esc;
93
+ } else {
94
+ str += expr[i];
95
+ }
96
+ i++;
97
+ }
98
+ i++; // closing quote
99
+ tokens.push({ t: T.STR, v: str });
100
+ continue;
101
+ }
102
+
103
+ // Template literals
104
+ if (ch === '`') {
105
+ const parts = []; // alternating: string, expr, string, expr, ...
106
+ let str = '';
107
+ i++;
108
+ while (i < len && expr[i] !== '`') {
109
+ if (expr[i] === '$' && i + 1 < len && expr[i + 1] === '{') {
110
+ parts.push(str);
111
+ str = '';
112
+ i += 2;
113
+ let depth = 1;
114
+ let inner = '';
115
+ while (i < len && depth > 0) {
116
+ if (expr[i] === '{') depth++;
117
+ else if (expr[i] === '}') { depth--; if (depth === 0) break; }
118
+ inner += expr[i++];
119
+ }
120
+ i++; // closing }
121
+ parts.push({ expr: inner });
122
+ } else {
123
+ if (expr[i] === '\\' && i + 1 < len) { str += expr[++i]; }
124
+ else str += expr[i];
125
+ i++;
126
+ }
127
+ }
128
+ i++; // closing backtick
129
+ parts.push(str);
130
+ tokens.push({ t: T.TMPL, v: parts });
131
+ continue;
132
+ }
133
+
134
+ // Identifiers & keywords
135
+ if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_' || ch === '$') {
136
+ let ident = '';
137
+ while (i < len && /[\w$]/.test(expr[i])) ident += expr[i++];
138
+ tokens.push({ t: T.IDENT, v: ident });
139
+ continue;
140
+ }
141
+
142
+ // Multi-char operators
143
+ const two = expr.slice(i, i + 3);
144
+ if (two === '===' || two === '!==' || two === '?.') {
145
+ if (two === '?.') {
146
+ tokens.push({ t: T.OP, v: '?.' });
147
+ i += 2;
148
+ } else {
149
+ tokens.push({ t: T.OP, v: two });
150
+ i += 3;
151
+ }
152
+ continue;
153
+ }
154
+ const pair = expr.slice(i, i + 2);
155
+ if (pair === '==' || pair === '!=' || pair === '<=' || pair === '>=' ||
156
+ pair === '&&' || pair === '||' || pair === '??' || pair === '?.' ||
157
+ pair === '=>') {
158
+ tokens.push({ t: T.OP, v: pair });
159
+ i += 2;
160
+ continue;
161
+ }
162
+
163
+ // Single char operators and punctuation
164
+ if ('+-*/%'.includes(ch)) {
165
+ tokens.push({ t: T.OP, v: ch });
166
+ i++; continue;
167
+ }
168
+ if ('<>=!'.includes(ch)) {
169
+ tokens.push({ t: T.OP, v: ch });
170
+ i++; continue;
171
+ }
172
+ // Spread operator: ...
173
+ if (ch === '.' && i + 2 < len && expr[i + 1] === '.' && expr[i + 2] === '.') {
174
+ tokens.push({ t: T.OP, v: '...' });
175
+ i += 3; continue;
176
+ }
177
+ if ('()[]{},.?:'.includes(ch)) {
178
+ tokens.push({ t: T.PUNC, v: ch });
179
+ i++; continue;
180
+ }
181
+
182
+ // Unknown - skip
183
+ i++;
184
+ }
185
+
186
+ tokens.push({ t: T.EOF, v: null });
187
+ return tokens;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Parser - Pratt (precedence climbing)
192
+ // ---------------------------------------------------------------------------
193
+ class Parser {
194
+ constructor(tokens, scope) {
195
+ this.tokens = tokens;
196
+ this.pos = 0;
197
+ this.scope = scope;
198
+ }
199
+
200
+ peek() { return this.tokens[this.pos]; }
201
+ next() { return this.tokens[this.pos++]; }
202
+
203
+ expect(type, val) {
204
+ const t = this.next();
205
+ if (t.t !== type || (val !== undefined && t.v !== val)) {
206
+ throw new Error(`Expected ${val || type} but got ${t.v}`);
207
+ }
208
+ return t;
209
+ }
210
+
211
+ match(type, val) {
212
+ const t = this.peek();
213
+ if (t.t === type && (val === undefined || t.v === val)) {
214
+ return this.next();
215
+ }
216
+ return null;
217
+ }
218
+
219
+ // Main entry
220
+ parse() {
221
+ const result = this.parseExpression(0);
222
+ return result;
223
+ }
224
+
225
+ // Precedence climbing
226
+ parseExpression(minPrec) {
227
+ let left = this.parseUnary();
228
+
229
+ while (true) {
230
+ const tok = this.peek();
231
+
232
+ // Ternary
233
+ if (tok.t === T.PUNC && tok.v === '?') {
234
+ // Distinguish ternary ? from optional chaining ?.
235
+ if (this.tokens[this.pos + 1]?.v !== '.') {
236
+ if (1 <= minPrec) break; // ternary has very low precedence
237
+ this.next(); // consume ?
238
+ const truthy = this.parseExpression(0);
239
+ this.expect(T.PUNC, ':');
240
+ const falsy = this.parseExpression(0);
241
+ left = { type: 'ternary', cond: left, truthy, falsy };
242
+ continue;
243
+ }
244
+ }
245
+
246
+ // Binary operators
247
+ if (tok.t === T.OP && tok.v in PREC) {
248
+ const prec = PREC[tok.v];
249
+ if (prec <= minPrec) break;
250
+ this.next();
251
+ const right = this.parseExpression(prec);
252
+ left = { type: 'binary', op: tok.v, left, right };
253
+ continue;
254
+ }
255
+
256
+ // instanceof and in as binary operators
257
+ if (tok.t === T.IDENT && (tok.v === 'instanceof' || tok.v === 'in') && PREC[tok.v] > minPrec) {
258
+ const prec = PREC[tok.v];
259
+ this.next();
260
+ const right = this.parseExpression(prec);
261
+ left = { type: 'binary', op: tok.v, left, right };
262
+ continue;
263
+ }
264
+
265
+ break;
266
+ }
267
+
268
+ return left;
269
+ }
270
+
271
+ parseUnary() {
272
+ const tok = this.peek();
273
+
274
+ // typeof
275
+ if (tok.t === T.IDENT && tok.v === 'typeof') {
276
+ this.next();
277
+ const arg = this.parseUnary();
278
+ return { type: 'typeof', arg };
279
+ }
280
+
281
+ // void
282
+ if (tok.t === T.IDENT && tok.v === 'void') {
283
+ this.next();
284
+ this.parseUnary(); // evaluate but discard
285
+ return { type: 'literal', value: undefined };
286
+ }
287
+
288
+ // !expr
289
+ if (tok.t === T.OP && tok.v === '!') {
290
+ this.next();
291
+ const arg = this.parseUnary();
292
+ return { type: 'not', arg };
293
+ }
294
+
295
+ // -expr, +expr
296
+ if (tok.t === T.OP && (tok.v === '-' || tok.v === '+')) {
297
+ this.next();
298
+ const arg = this.parseUnary();
299
+ return { type: 'unary', op: tok.v, arg };
300
+ }
301
+
302
+ return this.parsePostfix();
303
+ }
304
+
305
+ parsePostfix() {
306
+ let left = this.parsePrimary();
307
+
308
+ while (true) {
309
+ const tok = this.peek();
310
+
311
+ // Property access: a.b
312
+ if (tok.t === T.PUNC && tok.v === '.') {
313
+ this.next();
314
+ const prop = this.next();
315
+ left = { type: 'member', obj: left, prop: prop.v, computed: false };
316
+ // Check for method call: a.b()
317
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
318
+ left = this._parseCall(left);
319
+ }
320
+ continue;
321
+ }
322
+
323
+ // Optional chaining: a?.b, a?.[b], a?.()
324
+ if (tok.t === T.OP && tok.v === '?.') {
325
+ this.next();
326
+ const next = this.peek();
327
+ if (next.t === T.PUNC && next.v === '[') {
328
+ // a?.[expr]
329
+ this.next();
330
+ const prop = this.parseExpression(0);
331
+ this.expect(T.PUNC, ']');
332
+ left = { type: 'optional_member', obj: left, prop, computed: true };
333
+ } else if (next.t === T.PUNC && next.v === '(') {
334
+ // a?.()
335
+ left = { type: 'optional_call', callee: left, args: this._parseArgs() };
336
+ } else {
337
+ // a?.b
338
+ const prop = this.next();
339
+ left = { type: 'optional_member', obj: left, prop: prop.v, computed: false };
340
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
341
+ left = this._parseCall(left);
342
+ }
343
+ }
344
+ continue;
345
+ }
346
+
347
+ // Computed access: a[b]
348
+ if (tok.t === T.PUNC && tok.v === '[') {
349
+ this.next();
350
+ const prop = this.parseExpression(0);
351
+ this.expect(T.PUNC, ']');
352
+ left = { type: 'member', obj: left, prop, computed: true };
353
+ // Check for method call: a[b]()
354
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
355
+ left = this._parseCall(left);
356
+ }
357
+ continue;
358
+ }
359
+
360
+ // Function call: fn()
361
+ if (tok.t === T.PUNC && tok.v === '(') {
362
+ left = this._parseCall(left);
363
+ continue;
364
+ }
365
+
366
+ break;
367
+ }
368
+
369
+ return left;
370
+ }
371
+
372
+ _parseCall(callee) {
373
+ const args = this._parseArgs();
374
+ return { type: 'call', callee, args };
375
+ }
376
+
377
+ _parseArgs() {
378
+ this.expect(T.PUNC, '(');
379
+ const args = [];
380
+ while (!(this.peek().t === T.PUNC && this.peek().v === ')') && this.peek().t !== T.EOF) {
381
+ if (this.peek().t === T.OP && this.peek().v === '...') {
382
+ this.next();
383
+ args.push({ type: 'spread', arg: this.parseExpression(0) });
384
+ } else {
385
+ args.push(this.parseExpression(0));
386
+ }
387
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
388
+ }
389
+ this.expect(T.PUNC, ')');
390
+ return args;
391
+ }
392
+
393
+ parsePrimary() {
394
+ const tok = this.peek();
395
+
396
+ // Number literal
397
+ if (tok.t === T.NUM) {
398
+ this.next();
399
+ return { type: 'literal', value: tok.v };
400
+ }
401
+
402
+ // String literal
403
+ if (tok.t === T.STR) {
404
+ this.next();
405
+ return { type: 'literal', value: tok.v };
406
+ }
407
+
408
+ // Template literal
409
+ if (tok.t === T.TMPL) {
410
+ this.next();
411
+ return { type: 'template', parts: tok.v };
412
+ }
413
+
414
+ // Arrow function with parens: () =>, (a) =>, (a, b) =>
415
+ // or regular grouping: (expr)
416
+ if (tok.t === T.PUNC && tok.v === '(') {
417
+ const savedPos = this.pos;
418
+ this.next(); // consume (
419
+ const params = [];
420
+ let couldBeArrow = true;
421
+
422
+ if (this.peek().t === T.PUNC && this.peek().v === ')') {
423
+ // () => ... - no params
424
+ } else {
425
+ while (couldBeArrow) {
426
+ const p = this.peek();
427
+ if (p.t === T.IDENT && !KEYWORDS.has(p.v)) {
428
+ params.push(this.next().v);
429
+ if (this.peek().t === T.PUNC && this.peek().v === ',') {
430
+ this.next();
431
+ } else {
432
+ break;
433
+ }
434
+ } else {
435
+ couldBeArrow = false;
436
+ }
437
+ }
438
+ }
439
+
440
+ if (couldBeArrow && this.peek().t === T.PUNC && this.peek().v === ')') {
441
+ this.next(); // consume )
442
+ if (this.peek().t === T.OP && this.peek().v === '=>') {
443
+ this.next(); // consume =>
444
+ const body = this.parseExpression(0);
445
+ return { type: 'arrow', params, body };
446
+ }
447
+ }
448
+
449
+ // Not an arrow - restore and parse as grouping
450
+ this.pos = savedPos;
451
+ this.next(); // consume (
452
+ const expr = this.parseExpression(0);
453
+ this.expect(T.PUNC, ')');
454
+ return expr;
455
+ }
456
+
457
+ // Array literal
458
+ if (tok.t === T.PUNC && tok.v === '[') {
459
+ this.next();
460
+ const elements = [];
461
+ while (!(this.peek().t === T.PUNC && this.peek().v === ']') && this.peek().t !== T.EOF) {
462
+ if (this.peek().t === T.OP && this.peek().v === '...') {
463
+ this.next();
464
+ elements.push({ type: 'spread', arg: this.parseExpression(0) });
465
+ } else {
466
+ elements.push(this.parseExpression(0));
467
+ }
468
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
469
+ }
470
+ this.expect(T.PUNC, ']');
471
+ return { type: 'array', elements };
472
+ }
473
+
474
+ // Object literal
475
+ if (tok.t === T.PUNC && tok.v === '{') {
476
+ this.next();
477
+ const properties = [];
478
+ while (!(this.peek().t === T.PUNC && this.peek().v === '}') && this.peek().t !== T.EOF) {
479
+ // Spread in object: { ...obj }
480
+ if (this.peek().t === T.OP && this.peek().v === '...') {
481
+ this.next();
482
+ properties.push({ spread: true, value: this.parseExpression(0) });
483
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
484
+ continue;
485
+ }
486
+
487
+ const keyTok = this.next();
488
+ let key;
489
+ if (keyTok.t === T.IDENT || keyTok.t === T.STR) key = keyTok.v;
490
+ else if (keyTok.t === T.NUM) key = String(keyTok.v);
491
+ else throw new Error('Invalid object key: ' + keyTok.v);
492
+
493
+ // Shorthand property: { foo } means { foo: foo }
494
+ if (this.peek().t === T.PUNC && (this.peek().v === ',' || this.peek().v === '}')) {
495
+ properties.push({ key, value: { type: 'ident', name: key } });
496
+ } else {
497
+ this.expect(T.PUNC, ':');
498
+ properties.push({ key, value: this.parseExpression(0) });
499
+ }
500
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
501
+ }
502
+ this.expect(T.PUNC, '}');
503
+ return { type: 'object', properties };
504
+ }
505
+
506
+ // Identifiers & keywords
507
+ if (tok.t === T.IDENT) {
508
+ this.next();
509
+
510
+ // Keywords
511
+ if (tok.v === 'true') return { type: 'literal', value: true };
512
+ if (tok.v === 'false') return { type: 'literal', value: false };
513
+ if (tok.v === 'null') return { type: 'literal', value: null };
514
+ if (tok.v === 'undefined') return { type: 'literal', value: undefined };
515
+
516
+ // new keyword
517
+ if (tok.v === 'new') {
518
+ let classExpr = this.parsePrimary();
519
+ // Handle member access (e.g. ns.MyClass) without consuming call args
520
+ while (this.peek().t === T.PUNC && this.peek().v === '.') {
521
+ this.next();
522
+ const prop = this.next();
523
+ classExpr = { type: 'member', obj: classExpr, prop: prop.v, computed: false };
524
+ }
525
+ let args = [];
526
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
527
+ args = this._parseArgs();
528
+ }
529
+ return { type: 'new', callee: classExpr, args };
530
+ }
531
+
532
+ // Arrow function: x => expr
533
+ if (this.peek().t === T.OP && this.peek().v === '=>') {
534
+ this.next(); // consume =>
535
+ const body = this.parseExpression(0);
536
+ return { type: 'arrow', params: [tok.v], body };
537
+ }
538
+
539
+ return { type: 'ident', name: tok.v };
540
+ }
541
+
542
+ // Fallback - return undefined for unparseable
543
+ this.next();
544
+ return { type: 'literal', value: undefined };
545
+ }
546
+ }
547
+
548
+ // ---------------------------------------------------------------------------
549
+ // Evaluator - walks the AST, resolves against scope
550
+ // ---------------------------------------------------------------------------
551
+
552
+ /** Safe property access whitelist for built-in prototypes */
553
+ const SAFE_ARRAY_METHODS = new Set([
554
+ 'length', 'map', 'filter', 'find', 'findIndex', 'some', 'every',
555
+ 'reduce', 'reduceRight', 'forEach', 'includes', 'indexOf', 'lastIndexOf',
556
+ 'join', 'slice', 'concat', 'flat', 'flatMap', 'reverse', 'sort',
557
+ 'fill', 'keys', 'values', 'entries', 'at', 'toString',
558
+ ]);
559
+
560
+ const SAFE_STRING_METHODS = new Set([
561
+ 'length', 'charAt', 'charCodeAt', 'includes', 'indexOf', 'lastIndexOf',
562
+ 'slice', 'substring', 'trim', 'trimStart', 'trimEnd', 'toLowerCase',
563
+ 'toUpperCase', 'split', 'replace', 'replaceAll', 'match', 'search',
564
+ 'startsWith', 'endsWith', 'padStart', 'padEnd', 'repeat', 'at',
565
+ 'toString', 'valueOf',
566
+ ]);
567
+
568
+ const SAFE_NUMBER_METHODS = new Set([
569
+ 'toFixed', 'toPrecision', 'toString', 'valueOf',
570
+ ]);
571
+
572
+ const SAFE_OBJECT_METHODS = new Set([
573
+ 'hasOwnProperty', 'toString', 'valueOf',
574
+ ]);
575
+
576
+ const SAFE_MATH_PROPS = new Set([
577
+ 'PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2',
578
+ 'abs', 'ceil', 'floor', 'round', 'trunc', 'max', 'min', 'pow',
579
+ 'sqrt', 'sign', 'random', 'log', 'log2', 'log10',
580
+ ]);
581
+
582
+ const SAFE_JSON_PROPS = new Set(['parse', 'stringify']);
583
+
584
+ /**
585
+ * Check if property access is safe
586
+ */
587
+ function _isSafeAccess(obj, prop) {
588
+ // Never allow access to dangerous properties
589
+ const BLOCKED = new Set([
590
+ 'constructor', '__proto__', 'prototype', '__defineGetter__',
591
+ '__defineSetter__', '__lookupGetter__', '__lookupSetter__',
592
+ 'call', 'apply', 'bind',
593
+ ]);
594
+ if (typeof prop === 'string' && BLOCKED.has(prop)) return false;
595
+
596
+ // Always allow plain object/function property access and array index access
597
+ if (obj !== null && obj !== undefined && (typeof obj === 'object' || typeof obj === 'function')) return true;
598
+ if (typeof obj === 'string') return SAFE_STRING_METHODS.has(prop);
599
+ if (typeof obj === 'number') return SAFE_NUMBER_METHODS.has(prop);
600
+ return false;
601
+ }
602
+
603
+ function evaluate(node, scope) {
604
+ if (!node) return undefined;
605
+
606
+ switch (node.type) {
607
+ case 'literal':
608
+ return node.value;
609
+
610
+ case 'ident': {
611
+ const name = node.name;
612
+ // Check scope layers in order
613
+ for (const layer of scope) {
614
+ if (layer && typeof layer === 'object' && name in layer) {
615
+ return layer[name];
616
+ }
617
+ }
618
+ // Built-in globals (safe ones only)
619
+ if (name === 'Math') return Math;
620
+ if (name === 'JSON') return JSON;
621
+ if (name === 'Date') return Date;
622
+ if (name === 'Array') return Array;
623
+ if (name === 'Object') return Object;
624
+ if (name === 'String') return String;
625
+ if (name === 'Number') return Number;
626
+ if (name === 'Boolean') return Boolean;
627
+ if (name === 'parseInt') return parseInt;
628
+ if (name === 'parseFloat') return parseFloat;
629
+ if (name === 'isNaN') return isNaN;
630
+ if (name === 'isFinite') return isFinite;
631
+ if (name === 'Infinity') return Infinity;
632
+ if (name === 'NaN') return NaN;
633
+ if (name === 'encodeURIComponent') return encodeURIComponent;
634
+ if (name === 'decodeURIComponent') return decodeURIComponent;
635
+ if (name === 'console') return console;
636
+ if (name === 'Map') return Map;
637
+ if (name === 'Set') return Set;
638
+ if (name === 'URL') return URL;
639
+ if (name === 'URLSearchParams') return URLSearchParams;
640
+ return undefined;
641
+ }
642
+
643
+ case 'template': {
644
+ // Template literal with interpolation
645
+ let result = '';
646
+ for (const part of node.parts) {
647
+ if (typeof part === 'string') {
648
+ result += part;
649
+ } else if (part && part.expr) {
650
+ result += String(safeEval(part.expr, scope) ?? '');
651
+ }
652
+ }
653
+ return result;
654
+ }
655
+
656
+ case 'member': {
657
+ const obj = evaluate(node.obj, scope);
658
+ if (obj == null) return undefined;
659
+ const prop = node.computed ? evaluate(node.prop, scope) : node.prop;
660
+ if (!_isSafeAccess(obj, prop)) return undefined;
661
+ return obj[prop];
662
+ }
663
+
664
+ case 'optional_member': {
665
+ const obj = evaluate(node.obj, scope);
666
+ if (obj == null) return undefined;
667
+ const prop = node.computed ? evaluate(node.prop, scope) : node.prop;
668
+ if (!_isSafeAccess(obj, prop)) return undefined;
669
+ return obj[prop];
670
+ }
671
+
672
+ case 'call': {
673
+ const result = _resolveCall(node, scope, false);
674
+ return result;
675
+ }
676
+
677
+ case 'optional_call': {
678
+ const calleeNode = node.callee;
679
+ const args = _evalArgs(node.args, scope);
680
+ // Method call: obj?.method() - bind `this` to obj
681
+ if (calleeNode.type === 'member' || calleeNode.type === 'optional_member') {
682
+ const obj = evaluate(calleeNode.obj, scope);
683
+ if (obj == null) return undefined;
684
+ const prop = calleeNode.computed ? evaluate(calleeNode.prop, scope) : calleeNode.prop;
685
+ if (!_isSafeAccess(obj, prop)) return undefined;
686
+ const fn = obj[prop];
687
+ if (typeof fn !== 'function') return undefined;
688
+ return fn.apply(obj, args);
689
+ }
690
+ const callee = evaluate(calleeNode, scope);
691
+ if (callee == null) return undefined;
692
+ if (typeof callee !== 'function') return undefined;
693
+ return callee(...args);
694
+ }
695
+
696
+ case 'new': {
697
+ const Ctor = evaluate(node.callee, scope);
698
+ if (typeof Ctor !== 'function') return undefined;
699
+ // Only allow safe constructors (no RegExp - ReDoS risk, no Error - info leak)
700
+ if (Ctor === Date || Ctor === Array || Ctor === Map || Ctor === Set ||
701
+ Ctor === URL || Ctor === URLSearchParams) {
702
+ const args = _evalArgs(node.args, scope);
703
+ return new Ctor(...args);
704
+ }
705
+ return undefined;
706
+ }
707
+
708
+ case 'binary':
709
+ return _evalBinary(node, scope);
710
+
711
+ case 'unary': {
712
+ const val = evaluate(node.arg, scope);
713
+ return node.op === '-' ? -val : +val;
714
+ }
715
+
716
+ case 'not':
717
+ return !evaluate(node.arg, scope);
718
+
719
+ case 'typeof': {
720
+ try {
721
+ return typeof evaluate(node.arg, scope);
722
+ } catch {
723
+ return 'undefined';
724
+ }
725
+ }
726
+
727
+ case 'ternary': {
728
+ const cond = evaluate(node.cond, scope);
729
+ return cond ? evaluate(node.truthy, scope) : evaluate(node.falsy, scope);
730
+ }
731
+
732
+ case 'array': {
733
+ const arr = [];
734
+ for (const e of node.elements) {
735
+ if (e.type === 'spread') {
736
+ const iterable = evaluate(e.arg, scope);
737
+ if (iterable != null && typeof iterable[Symbol.iterator] === 'function') {
738
+ for (const v of iterable) arr.push(v);
739
+ }
740
+ } else {
741
+ arr.push(evaluate(e, scope));
742
+ }
743
+ }
744
+ return arr;
745
+ }
746
+
747
+ case 'object': {
748
+ const obj = {};
749
+ for (const prop of node.properties) {
750
+ if (prop.spread) {
751
+ const source = evaluate(prop.value, scope);
752
+ if (source != null && typeof source === 'object') {
753
+ Object.assign(obj, source);
754
+ }
755
+ } else {
756
+ obj[prop.key] = evaluate(prop.value, scope);
757
+ }
758
+ }
759
+ return obj;
760
+ }
761
+
762
+ case 'arrow': {
763
+ const paramNames = node.params;
764
+ const bodyNode = node.body;
765
+ const closedScope = scope;
766
+ return function(...args) {
767
+ const arrowScope = {};
768
+ paramNames.forEach((name, i) => { arrowScope[name] = args[i]; });
769
+ return evaluate(bodyNode, [arrowScope, ...closedScope]);
770
+ };
771
+ }
772
+
773
+ default:
774
+ return undefined;
775
+ }
776
+ }
777
+
778
+ /**
779
+ * Evaluate a list of argument AST nodes, flattening any spread elements.
780
+ */
781
+ function _evalArgs(argNodes, scope) {
782
+ const result = [];
783
+ for (const a of argNodes) {
784
+ if (a.type === 'spread') {
785
+ const iterable = evaluate(a.arg, scope);
786
+ if (iterable != null && typeof iterable[Symbol.iterator] === 'function') {
787
+ for (const v of iterable) result.push(v);
788
+ }
789
+ } else {
790
+ result.push(evaluate(a, scope));
791
+ }
792
+ }
793
+ return result;
794
+ }
795
+
796
+ /**
797
+ * Resolve and execute a function call safely.
798
+ */
799
+ function _resolveCall(node, scope) {
800
+ const callee = node.callee;
801
+ const args = _evalArgs(node.args, scope);
802
+
803
+ // Method call: obj.method() - bind `this` to obj
804
+ if (callee.type === 'member' || callee.type === 'optional_member') {
805
+ const obj = evaluate(callee.obj, scope);
806
+ if (obj == null) return undefined;
807
+ const prop = callee.computed ? evaluate(callee.prop, scope) : callee.prop;
808
+ if (!_isSafeAccess(obj, prop)) return undefined;
809
+ const fn = obj[prop];
810
+ if (typeof fn !== 'function') return undefined;
811
+ return fn.apply(obj, args);
812
+ }
813
+
814
+ // Direct call: fn(args)
815
+ const fn = evaluate(callee, scope);
816
+ if (typeof fn !== 'function') return undefined;
817
+ return fn(...args);
818
+ }
819
+
820
+ /**
821
+ * Evaluate binary expression.
822
+ */
823
+ function _evalBinary(node, scope) {
824
+ // Short-circuit for logical ops
825
+ if (node.op === '&&') {
826
+ const left = evaluate(node.left, scope);
827
+ return left ? evaluate(node.right, scope) : left;
828
+ }
829
+ if (node.op === '||') {
830
+ const left = evaluate(node.left, scope);
831
+ return left ? left : evaluate(node.right, scope);
832
+ }
833
+ if (node.op === '??') {
834
+ const left = evaluate(node.left, scope);
835
+ return left != null ? left : evaluate(node.right, scope);
836
+ }
837
+
838
+ const left = evaluate(node.left, scope);
839
+ const right = evaluate(node.right, scope);
840
+
841
+ switch (node.op) {
842
+ case '+': return left + right;
843
+ case '-': return left - right;
844
+ case '*': return left * right;
845
+ case '/': return left / right;
846
+ case '%': return left % right;
847
+ case '==': return left == right;
848
+ case '!=': return left != right;
849
+ case '===': return left === right;
850
+ case '!==': return left !== right;
851
+ case '<': return left < right;
852
+ case '>': return left > right;
853
+ case '<=': return left <= right;
854
+ case '>=': return left >= right;
855
+ case 'instanceof': return left instanceof right;
856
+ case 'in': return left in right;
857
+ default: return undefined;
858
+ }
859
+ }
860
+
861
+
862
+ // ---------------------------------------------------------------------------
863
+ // Public API
864
+ // ---------------------------------------------------------------------------
865
+
866
+ /**
867
+ * Safely evaluate a JS expression string against scope layers.
868
+ *
869
+ * @param {string} expr - expression string
870
+ * @param {object[]} scope - array of scope objects, checked in order
871
+ * Typical: [loopVars, state, { props, refs, $ }]
872
+ * @returns {*} - evaluation result, or undefined on error
873
+ */
874
+
875
+ // AST cache (LRU) - avoids re-tokenizing and re-parsing the same expression.
876
+ // Uses Map insertion-order: on hit, delete + re-set moves entry to the end.
877
+ // Eviction removes the least-recently-used (first) entry when at capacity.
878
+ const _astCache = new Map();
879
+ const _AST_CACHE_MAX = 512;
880
+
881
+ export function safeEval(expr, scope) {
882
+ try {
883
+ const trimmed = expr.trim();
884
+ if (!trimmed) return undefined;
885
+
886
+ // Fast path for simple identifiers: "count", "name", "visible"
887
+ // Avoids full tokenize→parse→evaluate overhead for the most common case.
888
+ if (/^[a-zA-Z_$][\w$]*$/.test(trimmed)) {
889
+ for (const layer of scope) {
890
+ if (layer && typeof layer === 'object' && trimmed in layer) {
891
+ return layer[trimmed];
892
+ }
893
+ }
894
+ // Fall through to full parser for built-in globals (Math, JSON, etc.)
895
+ }
896
+
897
+ // Check AST cache (LRU: move to end on hit)
898
+ let ast = _astCache.get(trimmed);
899
+ if (ast) {
900
+ _astCache.delete(trimmed);
901
+ _astCache.set(trimmed, ast);
902
+ } else {
903
+ const tokens = tokenize(trimmed);
904
+ const parser = new Parser(tokens, scope);
905
+ ast = parser.parse();
906
+
907
+ // Evict oldest entries when cache is full
908
+ if (_astCache.size >= _AST_CACHE_MAX) {
909
+ const first = _astCache.keys().next().value;
910
+ _astCache.delete(first);
911
+ }
912
+ _astCache.set(trimmed, ast);
913
+ }
914
+
915
+ return evaluate(ast, scope);
916
+ } catch (err) {
917
+ if (typeof console !== 'undefined' && console.debug) {
918
+ console.debug(`[zQuery EXPR_EVAL] Failed to evaluate: "${expr}"`, err.message);
919
+ }
920
+ return undefined;
921
+ }
922
+ }