@jxsuite/runtime 1.0.0 → 1.1.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.
- package/dist/runtime.js +393 -46
- package/dist/runtime.js.map +9 -8
- package/package.json +4 -3
- package/src/expression.ts +523 -28
- package/src/runtime.ts +114 -5
- package/src/statements.ts +212 -0
package/src/expression.ts
CHANGED
|
@@ -30,6 +30,11 @@ function scopeMap(state: JxScope): ScopeMapCtx | undefined {
|
|
|
30
30
|
interface CompileOpts {
|
|
31
31
|
statePrefix?: string;
|
|
32
32
|
eventParam?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Parameter-name order per named-formula state key, used by `call` sites to map positional
|
|
35
|
+
* argument lists onto the emitted formula functions' named-args object.
|
|
36
|
+
*/
|
|
37
|
+
formulaParams?: Record<string, string[]>;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
const MUTATING_OPS = new Set([
|
|
@@ -59,18 +64,154 @@ const BINARY_OPS = new Set([
|
|
|
59
64
|
">=",
|
|
60
65
|
"&&",
|
|
61
66
|
"||",
|
|
67
|
+
"??",
|
|
62
68
|
]);
|
|
63
69
|
const ASSIGNMENT_OPS = new Set(["=", "+=", "-=", "*=", "/="]);
|
|
64
70
|
const ARRAY_METHOD_OPS = new Set(["push", "pop", "shift", "unshift", "splice"]);
|
|
65
71
|
const AGGREGATE_OPS = new Set(["reduce", "map", "filter"]);
|
|
72
|
+
const CONDITIONAL_OPS = new Set(["?:", "switch"]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pure standard-library method operators (spec §19.4d): genuine `String.prototype` /
|
|
76
|
+
* `Array.prototype` / `Number.prototype` methods that never mutate their receiver — the ES2023
|
|
77
|
+
* change-by-copy family stands in where mutation would otherwise occur (`toSorted`, not `sort`).
|
|
78
|
+
* Receiver in `target`; `value` carries the argument (bare scalar) or argument list (array), the
|
|
79
|
+
* splice precedent. No token is invented.
|
|
80
|
+
*/
|
|
81
|
+
export const PURE_METHOD_OPS = new Set([
|
|
82
|
+
// Array.prototype (and the String.prototype homonyms: includes/indexOf/slice/at/concat)
|
|
83
|
+
"includes",
|
|
84
|
+
"indexOf",
|
|
85
|
+
"lastIndexOf",
|
|
86
|
+
"join",
|
|
87
|
+
"slice",
|
|
88
|
+
"concat",
|
|
89
|
+
"at",
|
|
90
|
+
"flat",
|
|
91
|
+
"toSorted",
|
|
92
|
+
"toReversed",
|
|
93
|
+
"toSpliced",
|
|
94
|
+
"with",
|
|
95
|
+
// String.prototype
|
|
96
|
+
"toUpperCase",
|
|
97
|
+
"toLowerCase",
|
|
98
|
+
"trim",
|
|
99
|
+
"trimStart",
|
|
100
|
+
"trimEnd",
|
|
101
|
+
"split",
|
|
102
|
+
"startsWith",
|
|
103
|
+
"endsWith",
|
|
104
|
+
"padStart",
|
|
105
|
+
"padEnd",
|
|
106
|
+
"replaceAll",
|
|
107
|
+
"repeat",
|
|
108
|
+
"charAt",
|
|
109
|
+
"normalize",
|
|
110
|
+
"toLocaleUpperCase",
|
|
111
|
+
"toLocaleLowerCase",
|
|
112
|
+
// Number.prototype
|
|
113
|
+
"toFixed",
|
|
114
|
+
"toPrecision",
|
|
115
|
+
"toLocaleString",
|
|
116
|
+
]);
|
|
66
117
|
|
|
67
118
|
export const BLESSED_OPERATORS = new Set([
|
|
68
119
|
...MUTATING_OPS,
|
|
69
120
|
...UNARY_OPS,
|
|
70
121
|
...BINARY_OPS,
|
|
71
122
|
...AGGREGATE_OPS,
|
|
123
|
+
...CONDITIONAL_OPS,
|
|
124
|
+
...PURE_METHOD_OPS,
|
|
125
|
+
"call",
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Pure globals a `call` node may target through the `window#/` scheme (spec §19.4c). Every entry is
|
|
130
|
+
* a genuine ECMAScript or WHATWG standard-library function with no side effects; anything off this
|
|
131
|
+
* list is a compile-time error, keeping formula purity decidable.
|
|
132
|
+
*/
|
|
133
|
+
export const BLESSED_GLOBALS = new Set([
|
|
134
|
+
// Global functions
|
|
135
|
+
"isNaN",
|
|
136
|
+
"isFinite",
|
|
137
|
+
"parseFloat",
|
|
138
|
+
"parseInt",
|
|
139
|
+
"encodeURIComponent",
|
|
140
|
+
"decodeURIComponent",
|
|
141
|
+
"structuredClone",
|
|
142
|
+
// Math
|
|
143
|
+
"Math/abs",
|
|
144
|
+
"Math/ceil",
|
|
145
|
+
"Math/floor",
|
|
146
|
+
"Math/round",
|
|
147
|
+
"Math/trunc",
|
|
148
|
+
"Math/sign",
|
|
149
|
+
"Math/sqrt",
|
|
150
|
+
"Math/cbrt",
|
|
151
|
+
"Math/pow",
|
|
152
|
+
"Math/exp",
|
|
153
|
+
"Math/log",
|
|
154
|
+
"Math/log2",
|
|
155
|
+
"Math/log10",
|
|
156
|
+
"Math/min",
|
|
157
|
+
"Math/max",
|
|
158
|
+
"Math/hypot",
|
|
159
|
+
// Number
|
|
160
|
+
"Number/isInteger",
|
|
161
|
+
"Number/isFinite",
|
|
162
|
+
"Number/isNaN",
|
|
163
|
+
"Number/parseFloat",
|
|
164
|
+
"Number/parseInt",
|
|
165
|
+
// JSON
|
|
166
|
+
"JSON/parse",
|
|
167
|
+
"JSON/stringify",
|
|
168
|
+
// Object
|
|
169
|
+
"Object/keys",
|
|
170
|
+
"Object/values",
|
|
171
|
+
"Object/entries",
|
|
172
|
+
"Object/fromEntries",
|
|
173
|
+
// Array
|
|
174
|
+
"Array/from",
|
|
175
|
+
"Array/isArray",
|
|
176
|
+
"Array/of",
|
|
177
|
+
// String
|
|
178
|
+
"String/fromCharCode",
|
|
179
|
+
"String/fromCodePoint",
|
|
180
|
+
// Intl (synthetic helpers — see BLESSED_HELPERS; Intl formatters are constructors,
|
|
181
|
+
// So the plain-function call shape wraps construct-then-format)
|
|
182
|
+
"Intl/formatNumber",
|
|
183
|
+
"Intl/formatDate",
|
|
184
|
+
"Intl/formatRelativeTime",
|
|
72
185
|
]);
|
|
73
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Synthetic blessed helpers (spec §19.4c): pure functions that have no direct `window` global
|
|
189
|
+
* because the underlying standard-library API is a constructor. The interpreter dispatches to
|
|
190
|
+
* these; the compiler emits the equivalent inline construct-then-format expression.
|
|
191
|
+
*/
|
|
192
|
+
const BLESSED_HELPERS: Record<string, (...a: unknown[]) => unknown> = {
|
|
193
|
+
"Intl/formatDate": (value, locale, options) =>
|
|
194
|
+
new Intl.DateTimeFormat(
|
|
195
|
+
locale as string | undefined,
|
|
196
|
+
options as Intl.DateTimeFormatOptions | undefined,
|
|
197
|
+
).format(new Date(value as string | number | Date)),
|
|
198
|
+
"Intl/formatNumber": (value, locale, options) =>
|
|
199
|
+
new Intl.NumberFormat(
|
|
200
|
+
locale as string | undefined,
|
|
201
|
+
options as Intl.NumberFormatOptions | undefined,
|
|
202
|
+
).format(value as number),
|
|
203
|
+
"Intl/formatRelativeTime": (value, unit, locale, options) =>
|
|
204
|
+
new Intl.RelativeTimeFormat(
|
|
205
|
+
locale as string | undefined,
|
|
206
|
+
options as Intl.RelativeTimeFormatOptions | undefined,
|
|
207
|
+
).format(value as number, unit as Intl.RelativeTimeFormatUnit),
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** Whether a `window#/…` callee ref is on the blessed pure-globals list. */
|
|
211
|
+
export function isBlessedGlobal(ref: string): boolean {
|
|
212
|
+
return ref.startsWith("window#/") && BLESSED_GLOBALS.has(ref.slice("window#/".length));
|
|
213
|
+
}
|
|
214
|
+
|
|
74
215
|
/**
|
|
75
216
|
* @param {string} op
|
|
76
217
|
* @returns {boolean}
|
|
@@ -85,6 +226,49 @@ interface IterCtx {
|
|
|
85
226
|
acc?: unknown;
|
|
86
227
|
item?: unknown;
|
|
87
228
|
index?: number;
|
|
229
|
+
/** Named-formula arguments bound for the current `call` body ($args/<name> refs). */
|
|
230
|
+
args?: Record<string, unknown>;
|
|
231
|
+
/** Call-nesting depth; capped at MAX_CALL_DEPTH to bound recursive formulas. */
|
|
232
|
+
callDepth?: number;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Recursion bound for `call` chains (formula → formula), mirroring MAX_REPORT_DEPTH. */
|
|
236
|
+
export const MAX_CALL_DEPTH = 64;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Editor-mode evaluation trace (spec §19.9). When passed, every node and operand reports its
|
|
240
|
+
* evaluated value keyed by its path within the expression tree, and the branch-selecting operators
|
|
241
|
+
* (`?:`, `switch` — plus the eager `&&`/`||`/`??`) yield values for ALL branches so a visual editor
|
|
242
|
+
* can badge every node. Absent (production), evaluation is untouched.
|
|
243
|
+
*/
|
|
244
|
+
export interface ExpressionTrace {
|
|
245
|
+
report: (path: (string | number)[], value: unknown) => void;
|
|
246
|
+
/** Current node path — internal; leave unset at the call boundary. */
|
|
247
|
+
path?: (string | number)[];
|
|
248
|
+
/** Recursion depth — internal; reporting stops beyond MAX_REPORT_DEPTH. */
|
|
249
|
+
depth?: number;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Reporting recursion bound: branch-forcing removes natural exit conditions, so cap the walk. */
|
|
253
|
+
export const MAX_REPORT_DEPTH = 64;
|
|
254
|
+
|
|
255
|
+
/** Derive the trace for a child operand at path segment(s) `seg`; undefined once capped. */
|
|
256
|
+
function subTrace(
|
|
257
|
+
trace: ExpressionTrace | undefined,
|
|
258
|
+
seg: string | number | (string | number)[],
|
|
259
|
+
): ExpressionTrace | undefined {
|
|
260
|
+
if (!trace) {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
const depth = (trace.depth ?? 0) + 1;
|
|
264
|
+
if (depth > MAX_REPORT_DEPTH) {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
depth,
|
|
269
|
+
path: [...(trace.path ?? []), ...(Array.isArray(seg) ? seg : [seg])],
|
|
270
|
+
report: trace.report,
|
|
271
|
+
};
|
|
88
272
|
}
|
|
89
273
|
|
|
90
274
|
/** Resolve an operand to its runtime value. */
|
|
@@ -93,6 +277,7 @@ function resolveOperand(
|
|
|
93
277
|
state: JxScope,
|
|
94
278
|
event: Event | null,
|
|
95
279
|
iterCtx?: IterCtx,
|
|
280
|
+
trace?: ExpressionTrace,
|
|
96
281
|
): unknown {
|
|
97
282
|
if (operand === null || operand === undefined) {
|
|
98
283
|
return operand;
|
|
@@ -100,20 +285,25 @@ function resolveOperand(
|
|
|
100
285
|
|
|
101
286
|
// Nested expression node
|
|
102
287
|
if (typeof operand === "object" && !Array.isArray(operand) && "operator" in operand) {
|
|
103
|
-
return evaluateExpression(operand as ExpressionNode, state, event, iterCtx);
|
|
288
|
+
return evaluateExpression(operand as ExpressionNode, state, event, iterCtx, trace);
|
|
104
289
|
}
|
|
105
290
|
|
|
106
291
|
// $ref pointer
|
|
107
292
|
if (typeof operand === "object" && !Array.isArray(operand) && "$ref" in operand) {
|
|
108
|
-
|
|
293
|
+
const resolved = resolveExprRef((operand as { $ref: string }).$ref, state, event, iterCtx);
|
|
294
|
+
trace?.report(trace.path ?? [], resolved);
|
|
295
|
+
return resolved;
|
|
109
296
|
}
|
|
110
297
|
|
|
111
298
|
// Array of operands (e.g., splice args)
|
|
112
299
|
if (Array.isArray(operand)) {
|
|
113
|
-
return operand.map((o: unknown
|
|
300
|
+
return operand.map((o: unknown, i: number) =>
|
|
301
|
+
resolveOperand(o, state, event, iterCtx, subTrace(trace, i)),
|
|
302
|
+
);
|
|
114
303
|
}
|
|
115
304
|
|
|
116
305
|
// Literal
|
|
306
|
+
trace?.report(trace.path ?? [], operand);
|
|
117
307
|
return operand;
|
|
118
308
|
}
|
|
119
309
|
|
|
@@ -125,6 +315,11 @@ function resolveExprRef(ref: string, state: JxScope, event: Event | null, iterCt
|
|
|
125
315
|
if (ref.startsWith("$reduce/")) {
|
|
126
316
|
return iterCtx?.acc;
|
|
127
317
|
}
|
|
318
|
+
if (ref.startsWith("$args/")) {
|
|
319
|
+
const parts = ref.slice("$args/".length).split("/");
|
|
320
|
+
const base = iterCtx?.args?.[parts[0]!];
|
|
321
|
+
return parts.length > 1 ? getPath(base, parts.slice(1).join("/")) : base;
|
|
322
|
+
}
|
|
128
323
|
if (ref.startsWith("event#/")) {
|
|
129
324
|
const path = ref.slice("event#/".length);
|
|
130
325
|
return getPath(event, path);
|
|
@@ -217,6 +412,19 @@ export function evaluateExpression(
|
|
|
217
412
|
state: JxScope,
|
|
218
413
|
event: Event | null,
|
|
219
414
|
iterCtx?: IterCtx,
|
|
415
|
+
trace?: ExpressionTrace,
|
|
416
|
+
): unknown {
|
|
417
|
+
const result = evaluateNode(node, state, event, iterCtx, trace);
|
|
418
|
+
trace?.report(trace.path ?? [], result);
|
|
419
|
+
return result;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function evaluateNode(
|
|
423
|
+
node: ExpressionNode,
|
|
424
|
+
state: JxScope,
|
|
425
|
+
event: Event | null,
|
|
426
|
+
iterCtx?: IterCtx,
|
|
427
|
+
trace?: ExpressionTrace,
|
|
220
428
|
): unknown {
|
|
221
429
|
const { operator, target, value, initial } = node;
|
|
222
430
|
|
|
@@ -226,7 +434,13 @@ export function evaluateExpression(
|
|
|
226
434
|
|
|
227
435
|
// ─── Unary ───
|
|
228
436
|
if (UNARY_OPS.has(operator) && !("value" in node)) {
|
|
229
|
-
const operand: unknown = resolveOperand(
|
|
437
|
+
const operand: unknown = resolveOperand(
|
|
438
|
+
target,
|
|
439
|
+
state,
|
|
440
|
+
event,
|
|
441
|
+
iterCtx,
|
|
442
|
+
subTrace(trace, "target"),
|
|
443
|
+
);
|
|
230
444
|
if (operator === "!") {
|
|
231
445
|
return !operand;
|
|
232
446
|
}
|
|
@@ -235,10 +449,124 @@ export function evaluateExpression(
|
|
|
235
449
|
}
|
|
236
450
|
}
|
|
237
451
|
|
|
452
|
+
// ─── Call (spec §19.4c) ───
|
|
453
|
+
if (operator === "call") {
|
|
454
|
+
const calleeRef = (target as { $ref?: string } | null)?.$ref;
|
|
455
|
+
if (typeof calleeRef !== "string") {
|
|
456
|
+
throw new TypeError("$expression: call target must be a $ref pointer");
|
|
457
|
+
}
|
|
458
|
+
const callDepth = (iterCtx?.callDepth ?? 0) + 1;
|
|
459
|
+
if (callDepth > MAX_CALL_DEPTH) {
|
|
460
|
+
throw new Error(`$expression: call depth exceeded (${MAX_CALL_DEPTH})`);
|
|
461
|
+
}
|
|
462
|
+
const argValues = Array.isArray(value)
|
|
463
|
+
? value.map((o: unknown, i: number) =>
|
|
464
|
+
resolveOperand(o, state, event, iterCtx, subTrace(trace, ["value", i])),
|
|
465
|
+
)
|
|
466
|
+
: [];
|
|
467
|
+
|
|
468
|
+
// Blessed pure global via the window#/ scheme (Math.max, JSON.parse, …).
|
|
469
|
+
if (calleeRef.startsWith("window#/")) {
|
|
470
|
+
if (!isBlessedGlobal(calleeRef)) {
|
|
471
|
+
throw new Error(`$expression: "${calleeRef}" is not a blessed pure global`);
|
|
472
|
+
}
|
|
473
|
+
const globalPath = calleeRef.slice("window#/".length);
|
|
474
|
+
const helper = BLESSED_HELPERS[globalPath];
|
|
475
|
+
if (helper) {
|
|
476
|
+
return helper(...argValues);
|
|
477
|
+
}
|
|
478
|
+
const fn = getPath(globalThis.window, globalPath) as (...a: unknown[]) => unknown;
|
|
479
|
+
const lastSlash = globalPath.lastIndexOf("/");
|
|
480
|
+
const thisArg =
|
|
481
|
+
lastSlash === -1
|
|
482
|
+
? globalThis.window
|
|
483
|
+
: getPath(globalThis.window, globalPath.slice(0, lastSlash));
|
|
484
|
+
return fn.apply(thisArg, argValues);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const callee = resolveExprRef(calleeRef, state, event, iterCtx);
|
|
488
|
+
// A scope callable (buildScope lowers parameterized formula entries to functions).
|
|
489
|
+
if (typeof callee === "function") {
|
|
490
|
+
return (callee as (...a: unknown[]) => unknown)(...argValues);
|
|
491
|
+
}
|
|
492
|
+
// A raw named-formula def (standalone evaluation, e.g. editor preview).
|
|
493
|
+
if (callee && typeof callee === "object" && "$expression" in callee) {
|
|
494
|
+
const def = callee as { $expression: ExpressionNode; parameters?: unknown[] };
|
|
495
|
+
const args: Record<string, unknown> = {};
|
|
496
|
+
for (const [i, p] of (def.parameters ?? []).entries()) {
|
|
497
|
+
const name = typeof p === "string" ? p : ((p as { name?: string } | null)?.name ?? "");
|
|
498
|
+
if (!name) {
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
args[name] =
|
|
502
|
+
argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
|
|
503
|
+
? (p as { default?: unknown }).default
|
|
504
|
+
: argValues[i];
|
|
505
|
+
}
|
|
506
|
+
// The callee body's node paths are its own tree, not the call site's — no trace inside.
|
|
507
|
+
return evaluateExpression(def.$expression, state, event, { args, callDepth });
|
|
508
|
+
}
|
|
509
|
+
throw new TypeError(`$expression: call target "${calleeRef}" is not callable`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ─── Conditional (pure) ───
|
|
513
|
+
if (CONDITIONAL_OPS.has(operator)) {
|
|
514
|
+
if (operator === "?:") {
|
|
515
|
+
const test = resolveOperand(target, state, event, iterCtx, subTrace(trace, "target"));
|
|
516
|
+
if (trace) {
|
|
517
|
+
// Editor mode: evaluate both branches so each carries a live value.
|
|
518
|
+
const consequent = resolveOperand(value, state, event, iterCtx, subTrace(trace, "value"));
|
|
519
|
+
const alternate = resolveOperand(
|
|
520
|
+
initial,
|
|
521
|
+
state,
|
|
522
|
+
event,
|
|
523
|
+
iterCtx,
|
|
524
|
+
subTrace(trace, "initial"),
|
|
525
|
+
);
|
|
526
|
+
return test ? consequent : alternate;
|
|
527
|
+
}
|
|
528
|
+
return test
|
|
529
|
+
? resolveOperand(value, state, event, iterCtx)
|
|
530
|
+
: resolveOperand(initial, state, event, iterCtx);
|
|
531
|
+
}
|
|
532
|
+
// Switch — value-keyed selection; discriminant matched against case keys by string form.
|
|
533
|
+
const discriminant = resolveOperand(target, state, event, iterCtx, subTrace(trace, "target"));
|
|
534
|
+
const key = String(discriminant);
|
|
535
|
+
const cases = node.cases ?? {};
|
|
536
|
+
const matched = Object.hasOwn(cases, key);
|
|
537
|
+
if (trace) {
|
|
538
|
+
let result: unknown;
|
|
539
|
+
for (const [k, caseOperand] of Object.entries(cases)) {
|
|
540
|
+
const v = resolveOperand(caseOperand, state, event, iterCtx, subTrace(trace, ["cases", k]));
|
|
541
|
+
if (k === key) {
|
|
542
|
+
result = v;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const fallback = resolveOperand(
|
|
546
|
+
node.default,
|
|
547
|
+
state,
|
|
548
|
+
event,
|
|
549
|
+
iterCtx,
|
|
550
|
+
subTrace(trace, "default"),
|
|
551
|
+
);
|
|
552
|
+
return matched ? result : fallback;
|
|
553
|
+
}
|
|
554
|
+
return matched
|
|
555
|
+
? resolveOperand(cases[key], state, event, iterCtx)
|
|
556
|
+
: resolveOperand(node.default, state, event, iterCtx);
|
|
557
|
+
}
|
|
558
|
+
|
|
238
559
|
// ─── Binary (pure) ───
|
|
239
560
|
if (BINARY_OPS.has(operator)) {
|
|
240
|
-
const left = resolveOperand(
|
|
241
|
-
|
|
561
|
+
const left = resolveOperand(
|
|
562
|
+
target,
|
|
563
|
+
state,
|
|
564
|
+
event,
|
|
565
|
+
iterCtx,
|
|
566
|
+
subTrace(trace, "target"),
|
|
567
|
+
) as number & string;
|
|
568
|
+
const right = resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")) as number &
|
|
569
|
+
string;
|
|
242
570
|
switch (operator) {
|
|
243
571
|
case "+": {
|
|
244
572
|
return left + right;
|
|
@@ -279,6 +607,9 @@ export function evaluateExpression(
|
|
|
279
607
|
case "||": {
|
|
280
608
|
return left || right;
|
|
281
609
|
}
|
|
610
|
+
case "??": {
|
|
611
|
+
return (left as unknown) ?? (right as unknown);
|
|
612
|
+
}
|
|
282
613
|
default: {
|
|
283
614
|
break;
|
|
284
615
|
}
|
|
@@ -287,7 +618,7 @@ export function evaluateExpression(
|
|
|
287
618
|
|
|
288
619
|
// ─── Assignment ───
|
|
289
620
|
if (ASSIGNMENT_OPS.has(operator)) {
|
|
290
|
-
const rhs = resolveOperand(value, state, event, iterCtx) as number;
|
|
621
|
+
const rhs = resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")) as number;
|
|
291
622
|
const { obj, key } = resolveWritableRef(
|
|
292
623
|
(target as { $ref: string }).$ref,
|
|
293
624
|
state,
|
|
@@ -328,13 +659,19 @@ export function evaluateExpression(
|
|
|
328
659
|
|
|
329
660
|
// ─── Array methods ───
|
|
330
661
|
if (ARRAY_METHOD_OPS.has(operator)) {
|
|
331
|
-
const arr: unknown[] = resolveOperand(
|
|
662
|
+
const arr: unknown[] = resolveOperand(
|
|
663
|
+
target,
|
|
664
|
+
state,
|
|
665
|
+
event,
|
|
666
|
+
iterCtx,
|
|
667
|
+
subTrace(trace, "target"),
|
|
668
|
+
) as unknown[];
|
|
332
669
|
switch (operator) {
|
|
333
670
|
case "push": {
|
|
334
|
-
return arr.push(resolveOperand(value, state, event, iterCtx));
|
|
671
|
+
return arr.push(resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")));
|
|
335
672
|
}
|
|
336
673
|
case "unshift": {
|
|
337
|
-
return arr.unshift(resolveOperand(value, state, event, iterCtx));
|
|
674
|
+
return arr.unshift(resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")));
|
|
338
675
|
}
|
|
339
676
|
case "pop": {
|
|
340
677
|
return arr.pop();
|
|
@@ -343,7 +680,13 @@ export function evaluateExpression(
|
|
|
343
680
|
return arr.shift();
|
|
344
681
|
}
|
|
345
682
|
case "splice": {
|
|
346
|
-
const args: unknown[] = resolveOperand(
|
|
683
|
+
const args: unknown[] = resolveOperand(
|
|
684
|
+
value,
|
|
685
|
+
state,
|
|
686
|
+
event,
|
|
687
|
+
iterCtx,
|
|
688
|
+
subTrace(trace, "value"),
|
|
689
|
+
) as unknown[];
|
|
347
690
|
return (arr.splice as (...a: unknown[]) => unknown[])(...args);
|
|
348
691
|
}
|
|
349
692
|
default: {
|
|
@@ -352,36 +695,67 @@ export function evaluateExpression(
|
|
|
352
695
|
}
|
|
353
696
|
}
|
|
354
697
|
|
|
698
|
+
// ─── Pure standard-library methods (spec §19.4d) ───
|
|
699
|
+
if (PURE_METHOD_OPS.has(operator)) {
|
|
700
|
+
const receiver = resolveOperand(target, state, event, iterCtx, subTrace(trace, "target"));
|
|
701
|
+
const args =
|
|
702
|
+
value === undefined
|
|
703
|
+
? []
|
|
704
|
+
: Array.isArray(value)
|
|
705
|
+
? (resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")) as unknown[])
|
|
706
|
+
: [resolveOperand(value, state, event, iterCtx, subTrace(trace, "value"))];
|
|
707
|
+
const method = (receiver as Record<string, unknown> | null | undefined)?.[operator];
|
|
708
|
+
if (typeof method !== "function") {
|
|
709
|
+
// Null-safe, like path reads: a missing receiver or method yields undefined, not a throw.
|
|
710
|
+
return undefined;
|
|
711
|
+
}
|
|
712
|
+
return (method as (...a: unknown[]) => unknown).apply(receiver, args);
|
|
713
|
+
}
|
|
714
|
+
|
|
355
715
|
// ─── Aggregates (pure) ───
|
|
356
716
|
if (AGGREGATE_OPS.has(operator)) {
|
|
357
|
-
const arr: unknown[] = resolveOperand(
|
|
717
|
+
const arr: unknown[] = resolveOperand(
|
|
718
|
+
target,
|
|
719
|
+
state,
|
|
720
|
+
event,
|
|
721
|
+
iterCtx,
|
|
722
|
+
subTrace(trace, "target"),
|
|
723
|
+
) as unknown[];
|
|
724
|
+
// Trace only the first iteration's per-item expression — one sample badge per node,
|
|
725
|
+
// Not one report per array element.
|
|
358
726
|
if (operator === "reduce") {
|
|
359
|
-
let acc: unknown = resolveOperand(initial, state, event, iterCtx);
|
|
727
|
+
let acc: unknown = resolveOperand(initial, state, event, iterCtx, subTrace(trace, "initial"));
|
|
360
728
|
for (const [index, item] of arr.entries()) {
|
|
361
|
-
acc = evaluateExpression(
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
729
|
+
acc = evaluateExpression(
|
|
730
|
+
value as ExpressionNode,
|
|
731
|
+
state,
|
|
732
|
+
event,
|
|
733
|
+
{ acc, index, item },
|
|
734
|
+
index === 0 ? subTrace(trace, "value") : undefined,
|
|
735
|
+
);
|
|
366
736
|
}
|
|
367
737
|
return acc;
|
|
368
738
|
}
|
|
369
739
|
if (operator === "map") {
|
|
370
740
|
return arr.map((item: unknown, index: number) =>
|
|
371
|
-
evaluateExpression(
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
741
|
+
evaluateExpression(
|
|
742
|
+
value as ExpressionNode,
|
|
743
|
+
state,
|
|
744
|
+
event,
|
|
745
|
+
{ ...iterCtx, index, item },
|
|
746
|
+
index === 0 ? subTrace(trace, "value") : undefined,
|
|
747
|
+
),
|
|
376
748
|
);
|
|
377
749
|
}
|
|
378
750
|
if (operator === "filter") {
|
|
379
751
|
return arr.filter((item: unknown, index: number) =>
|
|
380
|
-
evaluateExpression(
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
752
|
+
evaluateExpression(
|
|
753
|
+
value as ExpressionNode,
|
|
754
|
+
state,
|
|
755
|
+
event,
|
|
756
|
+
{ ...iterCtx, index, item },
|
|
757
|
+
index === 0 ? subTrace(trace, "value") : undefined,
|
|
758
|
+
),
|
|
385
759
|
);
|
|
386
760
|
}
|
|
387
761
|
}
|
|
@@ -419,6 +793,17 @@ function compileOperand(operand: unknown, opts: CompileOpts): string {
|
|
|
419
793
|
return JSON.stringify(operand);
|
|
420
794
|
}
|
|
421
795
|
|
|
796
|
+
/**
|
|
797
|
+
* The module-scope function name a named formula compiles to. Shared by call-site emission here and
|
|
798
|
+
* the declaration emission in the compiler targets.
|
|
799
|
+
*
|
|
800
|
+
* @param {string} key
|
|
801
|
+
* @returns {string}
|
|
802
|
+
*/
|
|
803
|
+
export function formulaFnName(key: string) {
|
|
804
|
+
return `_fx_${key.replaceAll(/[^A-Za-z0-9_$]/g, "_")}`;
|
|
805
|
+
}
|
|
806
|
+
|
|
422
807
|
/**
|
|
423
808
|
* Compile a $ref to its JS equivalent.
|
|
424
809
|
*
|
|
@@ -436,6 +821,10 @@ function compileRef(ref: string, opts: CompileOpts) {
|
|
|
436
821
|
if (ref.startsWith("$reduce/")) {
|
|
437
822
|
return "_acc";
|
|
438
823
|
}
|
|
824
|
+
if (ref.startsWith("$args/")) {
|
|
825
|
+
const parts = ref.slice("$args/".length).split("/");
|
|
826
|
+
return `_args${parts.map((p) => `[${JSON.stringify(p)}]`).join("")}`;
|
|
827
|
+
}
|
|
439
828
|
|
|
440
829
|
if (ref.startsWith("event#/")) {
|
|
441
830
|
const path = ref.slice("event#/".length);
|
|
@@ -488,6 +877,32 @@ function compileTarget(target: unknown, opts: CompileOpts): string {
|
|
|
488
877
|
* @param {CompileOpts} [opts]
|
|
489
878
|
* @returns {string}
|
|
490
879
|
*/
|
|
880
|
+
/**
|
|
881
|
+
* Emit the inline JS for a synthetic blessed helper (see BLESSED_HELPERS), or null for ordinary
|
|
882
|
+
* globals that compile to a plain `window.<path>` call.
|
|
883
|
+
*
|
|
884
|
+
* @param {string} path - The `window#/`-stripped callee path (e.g. "Intl/formatNumber")
|
|
885
|
+
* @param {string[]} args - Already-compiled argument expressions
|
|
886
|
+
* @returns {string | null}
|
|
887
|
+
*/
|
|
888
|
+
function compileHelperCall(path: string, args: string[]): string | null {
|
|
889
|
+
const a = (i: number) => args[i] ?? "undefined";
|
|
890
|
+
switch (path) {
|
|
891
|
+
case "Intl/formatNumber": {
|
|
892
|
+
return `new Intl.NumberFormat(${a(1)}, ${a(2)}).format(${a(0)})`;
|
|
893
|
+
}
|
|
894
|
+
case "Intl/formatDate": {
|
|
895
|
+
return `new Intl.DateTimeFormat(${a(1)}, ${a(2)}).format(new Date(${a(0)}))`;
|
|
896
|
+
}
|
|
897
|
+
case "Intl/formatRelativeTime": {
|
|
898
|
+
return `new Intl.RelativeTimeFormat(${a(2)}, ${a(3)}).format(${a(0)}, ${a(1)})`;
|
|
899
|
+
}
|
|
900
|
+
default: {
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
491
906
|
export function compileExpression(node: ExpressionNode, opts: CompileOpts = {}): string {
|
|
492
907
|
const { operator, target, value, initial } = node;
|
|
493
908
|
|
|
@@ -502,6 +917,51 @@ export function compileExpression(node: ExpressionNode, opts: CompileOpts = {}):
|
|
|
502
917
|
}
|
|
503
918
|
}
|
|
504
919
|
|
|
920
|
+
// ─── Call (spec §19.4c) ───
|
|
921
|
+
if (operator === "call") {
|
|
922
|
+
const calleeRef = (target as { $ref?: string } | null)?.$ref ?? "";
|
|
923
|
+
const args = Array.isArray(value) ? value.map((o: unknown) => compileOperand(o, opts)) : [];
|
|
924
|
+
if (calleeRef.startsWith("window#/")) {
|
|
925
|
+
if (!isBlessedGlobal(calleeRef)) {
|
|
926
|
+
throw new Error(`$expression: "${calleeRef}" is not a blessed pure global`);
|
|
927
|
+
}
|
|
928
|
+
const helperJs = compileHelperCall(calleeRef.slice("window#/".length), args);
|
|
929
|
+
if (helperJs) {
|
|
930
|
+
return helperJs;
|
|
931
|
+
}
|
|
932
|
+
return `window.${calleeRef.slice("window#/".length).replaceAll("/", ".")}(${args.join(", ")})`;
|
|
933
|
+
}
|
|
934
|
+
if (calleeRef.startsWith("#/state/")) {
|
|
935
|
+
const key = calleeRef.slice("#/state/".length);
|
|
936
|
+
const params = opts.formulaParams?.[key];
|
|
937
|
+
if (params) {
|
|
938
|
+
const named = params
|
|
939
|
+
.map((p, i) => `${JSON.stringify(p)}: ${args[i] ?? "undefined"}`)
|
|
940
|
+
.join(", ");
|
|
941
|
+
return `${formulaFnName(key)}(${opts.statePrefix ?? "state"}, { ${named} })`;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
// Fallback: the scope member itself is callable.
|
|
945
|
+
return `${compileRef(calleeRef, opts)}(${args.join(", ")})`;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ─── Conditional (pure) ───
|
|
949
|
+
if (CONDITIONAL_OPS.has(operator)) {
|
|
950
|
+
if (operator === "?:") {
|
|
951
|
+
const test: string = compileOperand(target, opts);
|
|
952
|
+
const consequent: string = compileOperand(value, opts);
|
|
953
|
+
const alternate: string = compileOperand(initial, opts);
|
|
954
|
+
return `(${test} ? ${consequent} : ${alternate})`;
|
|
955
|
+
}
|
|
956
|
+
// Switch — bind the discriminant's string form once, then chain strict-equality tests.
|
|
957
|
+
const discriminant: string = compileOperand(target, opts);
|
|
958
|
+
const chain = Object.entries(node.cases ?? {})
|
|
959
|
+
.map(([k, v]) => `_d === ${JSON.stringify(k)} ? ${compileOperand(v, opts)} : `)
|
|
960
|
+
.join("");
|
|
961
|
+
const fallback = "default" in node ? compileOperand(node.default, opts) : "undefined";
|
|
962
|
+
return `((_d) => ${chain}${fallback})(String(${discriminant}))`;
|
|
963
|
+
}
|
|
964
|
+
|
|
505
965
|
// ─── Binary (pure) ───
|
|
506
966
|
if (BINARY_OPS.has(operator)) {
|
|
507
967
|
const left: string = compileOperand(target, opts);
|
|
@@ -544,6 +1004,19 @@ export function compileExpression(node: ExpressionNode, opts: CompileOpts = {}):
|
|
|
544
1004
|
}
|
|
545
1005
|
}
|
|
546
1006
|
|
|
1007
|
+
// ─── Pure standard-library methods (spec §19.4d) ───
|
|
1008
|
+
if (PURE_METHOD_OPS.has(operator)) {
|
|
1009
|
+
const receiver: string = compileOperand(target, opts);
|
|
1010
|
+
const args: string =
|
|
1011
|
+
value === undefined
|
|
1012
|
+
? ""
|
|
1013
|
+
: Array.isArray(value)
|
|
1014
|
+
? value.map((o: unknown) => compileOperand(o, opts)).join(", ")
|
|
1015
|
+
: compileOperand(value, opts);
|
|
1016
|
+
// ?.method?.() matches the interpreter: missing receiver or method yields undefined.
|
|
1017
|
+
return `(${receiver})?.${operator}?.(${args})`;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
547
1020
|
// ─── Aggregates (pure) ───
|
|
548
1021
|
if (AGGREGATE_OPS.has(operator)) {
|
|
549
1022
|
const arr: string = compileOperand(target, opts);
|
|
@@ -573,3 +1046,25 @@ function getPath(obj: unknown, path: string): unknown {
|
|
|
573
1046
|
}
|
|
574
1047
|
return current;
|
|
575
1048
|
}
|
|
1049
|
+
|
|
1050
|
+
// ─── Statement-Engine Surface (spec §20) ─────────────────────────────────────
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Evaluate a bare operand (pointer, literal, or nested node) outside a node position — used by
|
|
1054
|
+
* statement execution (spec §20) for `if` tests and `$switch` discriminants.
|
|
1055
|
+
*/
|
|
1056
|
+
export function evaluateOperand(
|
|
1057
|
+
operand: unknown,
|
|
1058
|
+
state: JxScope,
|
|
1059
|
+
event: Event | null,
|
|
1060
|
+
iterCtx?: IterCtx,
|
|
1061
|
+
): unknown {
|
|
1062
|
+
return resolveOperand(operand, state, event, iterCtx);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** Compile a bare operand to JS source — the statement compiler's counterpart to evaluateOperand. */
|
|
1066
|
+
export function compileOperandSource(operand: unknown, opts: CompileOpts = {}): string {
|
|
1067
|
+
return compileOperand(operand, opts);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
export type { CompileOpts, IterCtx };
|