jprx 1.0.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/LICENSE +21 -0
- package/README.md +130 -0
- package/helpers/array.js +75 -0
- package/helpers/compare.js +26 -0
- package/helpers/conditional.js +34 -0
- package/helpers/datetime.js +54 -0
- package/helpers/format.js +20 -0
- package/helpers/logic.js +24 -0
- package/helpers/lookup.js +25 -0
- package/helpers/math.js +34 -0
- package/helpers/network.js +41 -0
- package/helpers/state.js +80 -0
- package/helpers/stats.js +39 -0
- package/helpers/string.js +49 -0
- package/index.js +69 -0
- package/package.json +24 -0
- package/parser.js +1517 -0
package/parser.js
ADDED
|
@@ -0,0 +1,1517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LIGHTVIEW CDOM PARSER
|
|
3
|
+
* Responsible for resolving reactive paths and expressions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const helpers = new Map();
|
|
7
|
+
const helperOptions = new Map();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Operator registration for JPRX.
|
|
11
|
+
* Operators map symbols to helper names and their positions.
|
|
12
|
+
*/
|
|
13
|
+
const operators = {
|
|
14
|
+
prefix: new Map(), // e.g., '++' -> { helper: 'increment', precedence: 70 }
|
|
15
|
+
postfix: new Map(), // e.g., '++' -> { helper: 'increment', precedence: 70 }
|
|
16
|
+
infix: new Map() // e.g., '+' -> { helper: 'add', precedence: 50 }
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Default precedence levels
|
|
20
|
+
const DEFAULT_PRECEDENCE = {
|
|
21
|
+
prefix: 80,
|
|
22
|
+
postfix: 80,
|
|
23
|
+
infix: 50
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Registers a global helper function.
|
|
28
|
+
*/
|
|
29
|
+
export const registerHelper = (name, fn, options = {}) => {
|
|
30
|
+
helpers.set(name, fn);
|
|
31
|
+
if (options) helperOptions.set(name, options);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Registers a helper as an operator with specified position.
|
|
36
|
+
* @param {string} helperName - The name of the registered helper
|
|
37
|
+
* @param {string} symbol - The operator symbol (e.g., '++', '+', '-')
|
|
38
|
+
* @param {'prefix'|'postfix'|'infix'} position - Operator position
|
|
39
|
+
* @param {number} [precedence] - Optional precedence (higher = binds tighter)
|
|
40
|
+
*/
|
|
41
|
+
export const registerOperator = (helperName, symbol, position, precedence) => {
|
|
42
|
+
if (!['prefix', 'postfix', 'infix'].includes(position)) {
|
|
43
|
+
throw new Error(`Invalid operator position: ${position}. Must be 'prefix', 'postfix', or 'infix'.`);
|
|
44
|
+
}
|
|
45
|
+
if (!helpers.has(helperName)) {
|
|
46
|
+
// Allow registration before helper exists (will be checked at parse time)
|
|
47
|
+
globalThis.console?.warn(`LightviewCDOM: Operator "${symbol}" registered for helper "${helperName}" which is not yet registered.`);
|
|
48
|
+
}
|
|
49
|
+
const prec = precedence ?? DEFAULT_PRECEDENCE[position];
|
|
50
|
+
operators[position].set(symbol, { helper: helperName, precedence: prec });
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const getLV = () => globalThis.Lightview || null;
|
|
54
|
+
export const getRegistry = () => getLV()?.registry || null;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Represents a mutable target (a property on an object).
|
|
58
|
+
* Allows cdom-bind and mutation helpers to work with plain object properties
|
|
59
|
+
* by treating them as if they had a .value property.
|
|
60
|
+
*/
|
|
61
|
+
export class BindingTarget {
|
|
62
|
+
constructor(parent, key) {
|
|
63
|
+
this.parent = parent;
|
|
64
|
+
this.key = key;
|
|
65
|
+
this.isBindingTarget = true; // Marker for duck-typing when instanceof fails
|
|
66
|
+
}
|
|
67
|
+
get value() { return this.parent[this.key]; }
|
|
68
|
+
set value(v) { this.parent[this.key] = v; }
|
|
69
|
+
get __parent__() { return this.parent; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Unwraps a signal-like value to its raw value.
|
|
74
|
+
* This should be used to establish reactive dependencies within a computed context.
|
|
75
|
+
*/
|
|
76
|
+
export const unwrapSignal = (val) => {
|
|
77
|
+
if (val && typeof val === 'function' && 'value' in val) {
|
|
78
|
+
return val.value;
|
|
79
|
+
}
|
|
80
|
+
if (val && typeof val === 'object' && !(globalThis.Node && val instanceof globalThis.Node) && 'value' in val) {
|
|
81
|
+
return val.value;
|
|
82
|
+
}
|
|
83
|
+
return val;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolves segments of a path against a root object, unwrapping signals as it goes.
|
|
89
|
+
*/
|
|
90
|
+
const traverse = (root, segments) => {
|
|
91
|
+
let current = root;
|
|
92
|
+
for (const segment of segments) {
|
|
93
|
+
if (!segment) continue;
|
|
94
|
+
current = unwrapSignal(current);
|
|
95
|
+
if (current == null) return undefined;
|
|
96
|
+
|
|
97
|
+
const key = segment.startsWith('[') ? segment.slice(1, -1) : segment;
|
|
98
|
+
current = current[key];
|
|
99
|
+
}
|
|
100
|
+
return unwrapSignal(current);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolves segments but keeps the final value as a proxy/signal for use as context.
|
|
105
|
+
* Only unwraps intermediate values during traversal.
|
|
106
|
+
*/
|
|
107
|
+
const traverseAsContext = (root, segments) => {
|
|
108
|
+
let current = root;
|
|
109
|
+
for (let i = 0; i < segments.length; i++) {
|
|
110
|
+
const segment = segments[i];
|
|
111
|
+
if (!segment) continue;
|
|
112
|
+
const key = segment.startsWith('[') ? segment.slice(1, -1) : segment;
|
|
113
|
+
|
|
114
|
+
const unwrapped = unwrapSignal(current);
|
|
115
|
+
if (unwrapped == null) return undefined;
|
|
116
|
+
|
|
117
|
+
if (i === segments.length - 1) {
|
|
118
|
+
return new BindingTarget(unwrapped, key);
|
|
119
|
+
}
|
|
120
|
+
current = unwrapped[key];
|
|
121
|
+
}
|
|
122
|
+
return current;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolves a path against a context and the global registry.
|
|
127
|
+
*/
|
|
128
|
+
export const resolvePath = (path, context) => {
|
|
129
|
+
if (typeof path !== 'string') return path;
|
|
130
|
+
|
|
131
|
+
const registry = getRegistry();
|
|
132
|
+
|
|
133
|
+
// Current context: .
|
|
134
|
+
if (path === '.') return unwrapSignal(context);
|
|
135
|
+
|
|
136
|
+
// Global absolute path: $/something
|
|
137
|
+
// First check if the root is in the local context's state (cdom-state)
|
|
138
|
+
// This allows $/cart to resolve from cdom-state: { cart: {...} }
|
|
139
|
+
if (path.startsWith('$/')) {
|
|
140
|
+
const [rootName, ...rest] = path.slice(2).split('/');
|
|
141
|
+
|
|
142
|
+
// Check local state chain first (via __state__ property set by handleCDOMState)
|
|
143
|
+
let cur = context;
|
|
144
|
+
while (cur) {
|
|
145
|
+
const localState = cur.__state__;
|
|
146
|
+
if (localState && rootName in localState) {
|
|
147
|
+
return traverse(localState[rootName], rest);
|
|
148
|
+
}
|
|
149
|
+
cur = cur.__parent__;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Then check global registry
|
|
153
|
+
const rootSignal = registry?.get(rootName);
|
|
154
|
+
if (!rootSignal) return undefined;
|
|
155
|
+
|
|
156
|
+
// Root can be a signal or a state proxy
|
|
157
|
+
return traverse(rootSignal, rest);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Relative path from current context
|
|
161
|
+
if (path.startsWith('./')) {
|
|
162
|
+
return traverse(context, path.slice(2).split('/'));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Parent path
|
|
166
|
+
if (path.startsWith('../')) {
|
|
167
|
+
return traverse(context?.__parent__, path.slice(3).split('/'));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Path with separators - treat as relative
|
|
171
|
+
if (path.includes('/') || path.includes('.')) {
|
|
172
|
+
return traverse(context, path.split(/[\/.]/));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Check if it's a single word that exists in the context
|
|
176
|
+
const unwrappedContext = unwrapSignal(context);
|
|
177
|
+
if (unwrappedContext && typeof unwrappedContext === 'object') {
|
|
178
|
+
if (path in unwrappedContext || unwrappedContext[path] !== undefined) {
|
|
179
|
+
// Use traverse with one segment to ensure signal unwrapping if context[path] is a signal
|
|
180
|
+
return traverse(unwrappedContext, [path]);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Return as literal
|
|
185
|
+
return path;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Like resolvePath, but preserves proxy/signal wrappers for use as evaluation context.
|
|
190
|
+
*/
|
|
191
|
+
export const resolvePathAsContext = (path, context) => {
|
|
192
|
+
if (typeof path !== 'string') return path;
|
|
193
|
+
|
|
194
|
+
const registry = getRegistry();
|
|
195
|
+
|
|
196
|
+
// Current context: .
|
|
197
|
+
if (path === '.') return context;
|
|
198
|
+
|
|
199
|
+
// Global absolute path: $/something
|
|
200
|
+
// First check if the root is in the local context's state (cdom-state)
|
|
201
|
+
if (path.startsWith('$/')) {
|
|
202
|
+
const segments = path.slice(2).split(/[/.]/);
|
|
203
|
+
const rootName = segments.shift();
|
|
204
|
+
|
|
205
|
+
// Check local state chain first
|
|
206
|
+
let cur = context;
|
|
207
|
+
while (cur) {
|
|
208
|
+
const localState = cur.__state__;
|
|
209
|
+
if (localState && rootName in localState) {
|
|
210
|
+
return traverseAsContext(localState[rootName], segments);
|
|
211
|
+
}
|
|
212
|
+
cur = cur.__parent__;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Then check global registry
|
|
216
|
+
const rootSignal = registry?.get(rootName);
|
|
217
|
+
if (!rootSignal) return undefined;
|
|
218
|
+
|
|
219
|
+
return traverseAsContext(rootSignal, segments);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Relative path from current context
|
|
223
|
+
if (path.startsWith('./')) {
|
|
224
|
+
return traverseAsContext(context, path.slice(2).split(/[\/.]/));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Parent path
|
|
228
|
+
if (path.startsWith('../')) {
|
|
229
|
+
return traverseAsContext(context?.__parent__, path.slice(3).split(/[\/.]/));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Path with separators
|
|
233
|
+
if (path.includes('/') || path.includes('.')) {
|
|
234
|
+
return traverseAsContext(context, path.split(/[\/.]/));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Single property access
|
|
238
|
+
const unwrappedContext = unwrapSignal(context);
|
|
239
|
+
if (unwrappedContext && typeof unwrappedContext === 'object') {
|
|
240
|
+
// If it looks like a variable name, assume it's a property on the context
|
|
241
|
+
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(path)) {
|
|
242
|
+
return new BindingTarget(unwrappedContext, path);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return path;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Represents a lazy value that will be resolved later with a specific context.
|
|
251
|
+
* Used for iteration placeholders like '_' and '$event'.
|
|
252
|
+
*/
|
|
253
|
+
class LazyValue {
|
|
254
|
+
constructor(fn) {
|
|
255
|
+
this.fn = fn;
|
|
256
|
+
this.isLazy = true;
|
|
257
|
+
}
|
|
258
|
+
resolve(context) {
|
|
259
|
+
return this.fn(context);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Helper to resolve an argument which could be a literal, a path, or an explosion.
|
|
265
|
+
* @param {string} arg - The argument string
|
|
266
|
+
* @param {object} context - The local context object
|
|
267
|
+
* @param {boolean} globalMode - If true, bare paths are resolved against global registry
|
|
268
|
+
*/
|
|
269
|
+
const resolveArgument = (arg, context, globalMode = false) => {
|
|
270
|
+
// 1. Quoted Strings
|
|
271
|
+
if ((arg.startsWith("'") && arg.endsWith("'")) || (arg.startsWith('"') && arg.endsWith('"'))) {
|
|
272
|
+
return { value: arg.slice(1, -1), isLiteral: true };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 2. Numbers
|
|
276
|
+
if (arg !== '' && !isNaN(Number(arg))) {
|
|
277
|
+
return { value: Number(arg), isLiteral: true };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 3. Booleans / Null
|
|
281
|
+
if (arg === 'true') return { value: true, isLiteral: true };
|
|
282
|
+
if (arg === 'false') return { value: false, isLiteral: true };
|
|
283
|
+
if (arg === 'null') return { value: null, isLiteral: true };
|
|
284
|
+
|
|
285
|
+
// 4. Placeholder / Lazy Evaluation (_)
|
|
286
|
+
if (arg === '_' || arg.startsWith('_/') || arg.startsWith('_.')) {
|
|
287
|
+
return {
|
|
288
|
+
value: new LazyValue((item) => {
|
|
289
|
+
if (arg === '_') return item;
|
|
290
|
+
const path = arg.startsWith('_.') ? arg.slice(2) : arg.slice(2);
|
|
291
|
+
return resolvePath(path, item);
|
|
292
|
+
}),
|
|
293
|
+
isLazy: true
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 5. Event Placeholder ($event)
|
|
298
|
+
if (arg === '$event' || arg.startsWith('$event/') || arg.startsWith('$event.')) {
|
|
299
|
+
return {
|
|
300
|
+
value: new LazyValue((event) => {
|
|
301
|
+
if (arg === '$event') return event;
|
|
302
|
+
const path = arg.startsWith('$event.') ? arg.slice(7) : arg.slice(7);
|
|
303
|
+
return resolvePath(path, event);
|
|
304
|
+
}),
|
|
305
|
+
isLazy: true
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 6. Object / Array Literals (Concise)
|
|
310
|
+
if (arg.startsWith('{') || arg.startsWith('[')) {
|
|
311
|
+
try {
|
|
312
|
+
const data = parseJPRX(arg);
|
|
313
|
+
|
|
314
|
+
// Define a recursive resolver for template objects
|
|
315
|
+
const resolveTemplate = (node, context) => {
|
|
316
|
+
if (typeof node === 'string') {
|
|
317
|
+
if (node.startsWith('$')) {
|
|
318
|
+
const res = resolveExpression(node, context);
|
|
319
|
+
const final = (res instanceof LazyValue) ? res.resolve(context) : res;
|
|
320
|
+
return unwrapSignal(final);
|
|
321
|
+
}
|
|
322
|
+
if (node === '_' || node.startsWith('_/') || node.startsWith('_.')) {
|
|
323
|
+
const path = (node.startsWith('_.') || node.startsWith('_/')) ? node.slice(2) : node.slice(2);
|
|
324
|
+
const res = node === '_' ? context : resolvePath(path, context);
|
|
325
|
+
return unwrapSignal(res);
|
|
326
|
+
}
|
|
327
|
+
if (node.startsWith('../')) return unwrapSignal(resolvePath(node, context));
|
|
328
|
+
}
|
|
329
|
+
if (Array.isArray(node)) return node.map(n => resolveTemplate(n, context));
|
|
330
|
+
if (node && typeof node === 'object') {
|
|
331
|
+
const res = {};
|
|
332
|
+
for (const k in node) res[k] = resolveTemplate(node[k], context);
|
|
333
|
+
return res;
|
|
334
|
+
}
|
|
335
|
+
return node;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Check if it contains any reactive parts
|
|
339
|
+
const hasReactive = (obj) => {
|
|
340
|
+
if (typeof obj === 'string') {
|
|
341
|
+
return obj.startsWith('$') || obj.startsWith('_') || obj.startsWith('../');
|
|
342
|
+
}
|
|
343
|
+
if (Array.isArray(obj)) return obj.some(hasReactive);
|
|
344
|
+
if (obj && typeof obj === 'object') return Object.values(obj).some(hasReactive);
|
|
345
|
+
return false;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
if (hasReactive(data)) {
|
|
349
|
+
return {
|
|
350
|
+
value: new LazyValue((context) => resolveTemplate(data, context)),
|
|
351
|
+
isLazy: true
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
return { value: data, isLiteral: true };
|
|
355
|
+
} catch (e) {
|
|
356
|
+
// Fallback to path resolution if JSON parse fails
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 7. Nested Function Calls
|
|
361
|
+
if (arg.includes('(')) {
|
|
362
|
+
let nestedExpr = arg;
|
|
363
|
+
if (arg.startsWith('/')) {
|
|
364
|
+
nestedExpr = '$' + arg;
|
|
365
|
+
} else if (globalMode && !arg.startsWith('$') && !arg.startsWith('./')) {
|
|
366
|
+
nestedExpr = `$/${arg}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const val = resolveExpression(nestedExpr, context);
|
|
370
|
+
if (val instanceof LazyValue) {
|
|
371
|
+
return { value: val, isLazy: true };
|
|
372
|
+
}
|
|
373
|
+
return { value: val, isSignal: false };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 8. Path normalization
|
|
377
|
+
let normalizedPath;
|
|
378
|
+
if (arg.startsWith('/')) {
|
|
379
|
+
normalizedPath = '$' + arg;
|
|
380
|
+
} else if (arg.startsWith('$') || arg.startsWith('./') || arg.startsWith('../')) {
|
|
381
|
+
normalizedPath = arg;
|
|
382
|
+
} else if (globalMode) {
|
|
383
|
+
normalizedPath = `$/${arg}`;
|
|
384
|
+
} else {
|
|
385
|
+
normalizedPath = `./${arg}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 9. Explosion operator (path... or path...prop)
|
|
389
|
+
const explosionIdx = arg.indexOf('...');
|
|
390
|
+
if (explosionIdx !== -1) {
|
|
391
|
+
// Use normalizedPath up to the explosion point
|
|
392
|
+
// Note: indexOf('...') might be Different in normalizedPath if we added $/
|
|
393
|
+
const normExplosionIdx = normalizedPath.indexOf('...');
|
|
394
|
+
const pathPart = normalizedPath.slice(0, normExplosionIdx);
|
|
395
|
+
const propName = arg.slice(explosionIdx + 3);
|
|
396
|
+
|
|
397
|
+
const parent = resolvePath(pathPart, context);
|
|
398
|
+
const unwrappedParent = unwrapSignal(parent);
|
|
399
|
+
|
|
400
|
+
if (Array.isArray(unwrappedParent)) {
|
|
401
|
+
const values = unwrappedParent.map(item => {
|
|
402
|
+
const unwrappedItem = unwrapSignal(item);
|
|
403
|
+
if (!propName) return unwrappedItem;
|
|
404
|
+
return unwrappedItem && typeof unwrappedItem === 'object' ? unwrapSignal(unwrappedItem[propName]) : undefined;
|
|
405
|
+
});
|
|
406
|
+
return { value: values, isExplosion: true };
|
|
407
|
+
} else if (unwrappedParent && typeof unwrappedParent === 'object') {
|
|
408
|
+
if (!propName) return { value: unwrappedParent, isExplosion: true };
|
|
409
|
+
const val = unwrappedParent[propName];
|
|
410
|
+
return { value: unwrapSignal(val), isExplosion: true };
|
|
411
|
+
}
|
|
412
|
+
return { value: undefined, isExplosion: true };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const value = resolvePathAsContext(normalizedPath, context);
|
|
416
|
+
return { value, isExplosion: false };
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
// ============================================================================
|
|
421
|
+
// JPRX TOKENIZER & PRATT PARSER
|
|
422
|
+
// ============================================================================
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Token types for JPRX expressions.
|
|
426
|
+
*/
|
|
427
|
+
const TokenType = {
|
|
428
|
+
PATH: 'PATH', // $/user/age, ./name, ../parent
|
|
429
|
+
LITERAL: 'LITERAL', // 123, "hello", true, false, null
|
|
430
|
+
OPERATOR: 'OPERATOR', // +, -, *, /, ++, --, etc.
|
|
431
|
+
LPAREN: 'LPAREN', // (
|
|
432
|
+
RPAREN: 'RPAREN', // )
|
|
433
|
+
COMMA: 'COMMA', // ,
|
|
434
|
+
EXPLOSION: 'EXPLOSION', // ... suffix
|
|
435
|
+
PLACEHOLDER: 'PLACEHOLDER', // _, _/path
|
|
436
|
+
EVENT: 'EVENT', // $event, $event.target
|
|
437
|
+
EOF: 'EOF'
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Get all registered operator symbols sorted by length (longest first).
|
|
442
|
+
* This ensures we match '++' before '+'.
|
|
443
|
+
*/
|
|
444
|
+
const getOperatorSymbols = () => {
|
|
445
|
+
const allOps = new Set([
|
|
446
|
+
...operators.prefix.keys(),
|
|
447
|
+
...operators.postfix.keys(),
|
|
448
|
+
...operators.infix.keys()
|
|
449
|
+
]);
|
|
450
|
+
return [...allOps].sort((a, b) => b.length - a.length);
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Checks if a symbol is registered as any type of operator.
|
|
455
|
+
*/
|
|
456
|
+
const isOperator = (symbol) => {
|
|
457
|
+
return operators.prefix.has(symbol) ||
|
|
458
|
+
operators.postfix.has(symbol) ||
|
|
459
|
+
operators.infix.has(symbol);
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Tokenizes a JPRX expression into an array of tokens.
|
|
464
|
+
* @param {string} expr - The expression to tokenize
|
|
465
|
+
* @returns {Array<{type: string, value: any}>}
|
|
466
|
+
*/
|
|
467
|
+
const tokenize = (expr) => {
|
|
468
|
+
const tokens = [];
|
|
469
|
+
let i = 0;
|
|
470
|
+
const len = expr.length;
|
|
471
|
+
const opSymbols = getOperatorSymbols();
|
|
472
|
+
|
|
473
|
+
while (i < len) {
|
|
474
|
+
// Skip whitespace
|
|
475
|
+
if (/\s/.test(expr[i])) {
|
|
476
|
+
i++;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Special: $ followed immediately by an operator symbol
|
|
481
|
+
// In expressions like "$++/count", the $ is just the JPRX delimiter
|
|
482
|
+
// and ++ is a prefix operator applied to /count
|
|
483
|
+
if (expr[i] === '$' && i + 1 < len) {
|
|
484
|
+
// Check if next chars are an operator
|
|
485
|
+
let isOpAfter = false;
|
|
486
|
+
for (const op of opSymbols) {
|
|
487
|
+
if (expr.slice(i + 1, i + 1 + op.length) === op) {
|
|
488
|
+
isOpAfter = true;
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (isOpAfter) {
|
|
493
|
+
// Skip the $, it's just a delimiter
|
|
494
|
+
i++;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Parentheses
|
|
500
|
+
if (expr[i] === '(') {
|
|
501
|
+
tokens.push({ type: TokenType.LPAREN, value: '(' });
|
|
502
|
+
i++;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (expr[i] === ')') {
|
|
506
|
+
tokens.push({ type: TokenType.RPAREN, value: ')' });
|
|
507
|
+
i++;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Comma
|
|
512
|
+
if (expr[i] === ',') {
|
|
513
|
+
tokens.push({ type: TokenType.COMMA, value: ',' });
|
|
514
|
+
i++;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Check for operators (longest match first)
|
|
519
|
+
let matchedOp = null;
|
|
520
|
+
for (const op of opSymbols) {
|
|
521
|
+
if (expr.slice(i, i + op.length) === op) {
|
|
522
|
+
// Make sure it's not part of a path (e.g., don't match + in $/a+b if that's a path)
|
|
523
|
+
// Operators should be surrounded by whitespace or other tokens
|
|
524
|
+
const before = i > 0 ? expr[i - 1] : ' ';
|
|
525
|
+
const after = i + op.length < len ? expr[i + op.length] : ' ';
|
|
526
|
+
|
|
527
|
+
const isInfix = operators.infix.has(op);
|
|
528
|
+
const isPrefix = operators.prefix.has(op);
|
|
529
|
+
const isPostfix = operators.postfix.has(op);
|
|
530
|
+
|
|
531
|
+
// For infix-only operators (like /, +, -, >, <, >=, <=, !=), we now REQUIRE surrounding whitespace
|
|
532
|
+
// This prevents collision with path separators (especially for /)
|
|
533
|
+
if (isInfix && !isPrefix && !isPostfix) {
|
|
534
|
+
if (/\s/.test(before) && /\s/.test(after)) {
|
|
535
|
+
matchedOp = op;
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Accept prefix/postfix operator if:
|
|
542
|
+
// - Previous char is whitespace, ), or another operator end
|
|
543
|
+
// - Or we're at start of expression
|
|
544
|
+
// - And next char is whitespace, (, $, ., /, digit, quote, or another operator start
|
|
545
|
+
const validBefore = /[\s)]/.test(before) || i === 0 ||
|
|
546
|
+
tokens.length === 0 ||
|
|
547
|
+
tokens[tokens.length - 1].type === TokenType.LPAREN ||
|
|
548
|
+
tokens[tokens.length - 1].type === TokenType.COMMA ||
|
|
549
|
+
tokens[tokens.length - 1].type === TokenType.OPERATOR;
|
|
550
|
+
const validAfter = /[\s($./'"0-9_]/.test(after) ||
|
|
551
|
+
i + op.length >= len ||
|
|
552
|
+
opSymbols.some(o => expr.slice(i + op.length).startsWith(o));
|
|
553
|
+
|
|
554
|
+
if (validBefore || validAfter) {
|
|
555
|
+
matchedOp = op;
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (matchedOp) {
|
|
562
|
+
tokens.push({ type: TokenType.OPERATOR, value: matchedOp });
|
|
563
|
+
i += matchedOp.length;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Quoted strings
|
|
568
|
+
if (expr[i] === '"' || expr[i] === "'") {
|
|
569
|
+
const quote = expr[i];
|
|
570
|
+
let str = '';
|
|
571
|
+
i++; // skip opening quote
|
|
572
|
+
while (i < len && expr[i] !== quote) {
|
|
573
|
+
if (expr[i] === '\\' && i + 1 < len) {
|
|
574
|
+
i++;
|
|
575
|
+
if (expr[i] === 'n') str += '\n';
|
|
576
|
+
else if (expr[i] === 't') str += '\t';
|
|
577
|
+
else str += expr[i];
|
|
578
|
+
} else {
|
|
579
|
+
str += expr[i];
|
|
580
|
+
}
|
|
581
|
+
i++;
|
|
582
|
+
}
|
|
583
|
+
i++; // skip closing quote
|
|
584
|
+
tokens.push({ type: TokenType.LITERAL, value: str });
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Numbers (including negative numbers at start or after operator)
|
|
589
|
+
if (/\d/.test(expr[i]) ||
|
|
590
|
+
(expr[i] === '-' && /\d/.test(expr[i + 1]) &&
|
|
591
|
+
(tokens.length === 0 ||
|
|
592
|
+
tokens[tokens.length - 1].type === TokenType.OPERATOR ||
|
|
593
|
+
tokens[tokens.length - 1].type === TokenType.LPAREN ||
|
|
594
|
+
tokens[tokens.length - 1].type === TokenType.COMMA))) {
|
|
595
|
+
let num = '';
|
|
596
|
+
if (expr[i] === '-') {
|
|
597
|
+
num = '-';
|
|
598
|
+
i++;
|
|
599
|
+
}
|
|
600
|
+
while (i < len && /[\d.]/.test(expr[i])) {
|
|
601
|
+
num += expr[i];
|
|
602
|
+
i++;
|
|
603
|
+
}
|
|
604
|
+
tokens.push({ type: TokenType.LITERAL, value: parseFloat(num) });
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Placeholder (_)
|
|
609
|
+
if (expr[i] === '_' && (i + 1 >= len || !/[a-zA-Z0-9]/.test(expr[i + 1]) || expr[i + 1] === '/' || expr[i + 1] === '.')) {
|
|
610
|
+
let placeholder = '_';
|
|
611
|
+
i++;
|
|
612
|
+
// Check for path after placeholder: _/path or _.path
|
|
613
|
+
if (i < len && (expr[i] === '/' || expr[i] === '.')) {
|
|
614
|
+
while (i < len && !/[\s,)(]/.test(expr[i])) {
|
|
615
|
+
placeholder += expr[i];
|
|
616
|
+
i++;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
tokens.push({ type: TokenType.PLACEHOLDER, value: placeholder });
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// $event placeholder
|
|
624
|
+
if (expr.slice(i, i + 6) === '$event') {
|
|
625
|
+
let eventPath = '$event';
|
|
626
|
+
i += 6;
|
|
627
|
+
while (i < len && /[a-zA-Z0-9_./]/.test(expr[i])) {
|
|
628
|
+
eventPath += expr[i];
|
|
629
|
+
i++;
|
|
630
|
+
}
|
|
631
|
+
tokens.push({ type: TokenType.EVENT, value: eventPath });
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Paths: start with $, ., or /
|
|
636
|
+
if (expr[i] === '$' || expr[i] === '.' || expr[i] === '/') {
|
|
637
|
+
let path = '';
|
|
638
|
+
// Consume the path, but stop at operators
|
|
639
|
+
while (i < len) {
|
|
640
|
+
// Check if we've hit an operator
|
|
641
|
+
let isOp = false;
|
|
642
|
+
for (const op of opSymbols) {
|
|
643
|
+
if (expr.slice(i, i + op.length) === op) {
|
|
644
|
+
const isInfix = operators.infix.has(op);
|
|
645
|
+
const isPrefix = operators.prefix.has(op);
|
|
646
|
+
const isPostfix = operators.postfix.has(op);
|
|
647
|
+
|
|
648
|
+
// Strict infix (like /) MUST have spaces to break the path
|
|
649
|
+
if (isInfix && !isPrefix && !isPostfix) {
|
|
650
|
+
const after = i + op.length < len ? expr[i + op.length] : ' ';
|
|
651
|
+
if (/\s/.test(expr[i - 1]) && /\s/.test(after)) {
|
|
652
|
+
isOp = true;
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Prefix/Postfix: if they appear after a path, they are operators
|
|
659
|
+
// (e.g., $/count++)
|
|
660
|
+
if (path.length > 0 && path[path.length - 1] !== '/') {
|
|
661
|
+
isOp = true;
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (isOp) break;
|
|
667
|
+
|
|
668
|
+
// Stop at whitespace, comma, or parentheses
|
|
669
|
+
if (/[\s,()]/.test(expr[i])) break;
|
|
670
|
+
|
|
671
|
+
// Check for explosion operator
|
|
672
|
+
if (expr.slice(i, i + 3) === '...') {
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
path += expr[i];
|
|
677
|
+
i++;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Check for explosion suffix
|
|
681
|
+
if (expr.slice(i, i + 3) === '...') {
|
|
682
|
+
tokens.push({ type: TokenType.PATH, value: path });
|
|
683
|
+
tokens.push({ type: TokenType.EXPLOSION, value: '...' });
|
|
684
|
+
i += 3;
|
|
685
|
+
} else {
|
|
686
|
+
tokens.push({ type: TokenType.PATH, value: path });
|
|
687
|
+
}
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Boolean/null literals or identifiers
|
|
692
|
+
if (/[a-zA-Z]/.test(expr[i])) {
|
|
693
|
+
let ident = '';
|
|
694
|
+
while (i < len && /[a-zA-Z0-9_]/.test(expr[i])) {
|
|
695
|
+
ident += expr[i];
|
|
696
|
+
i++;
|
|
697
|
+
}
|
|
698
|
+
if (ident === 'true') tokens.push({ type: TokenType.LITERAL, value: true });
|
|
699
|
+
else if (ident === 'false') tokens.push({ type: TokenType.LITERAL, value: false });
|
|
700
|
+
else if (ident === 'null') tokens.push({ type: TokenType.LITERAL, value: null });
|
|
701
|
+
else tokens.push({ type: TokenType.PATH, value: ident }); // treat as path/identifier
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Unknown character, skip
|
|
706
|
+
i++;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
tokens.push({ type: TokenType.EOF, value: null });
|
|
710
|
+
return tokens;
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Checks if an expression contains operator syntax (not just function calls).
|
|
715
|
+
* Used to determine whether to use Pratt parser or legacy parser.
|
|
716
|
+
*
|
|
717
|
+
* CONSERVATIVE: Only detect explicit patterns to avoid false positives.
|
|
718
|
+
* - Prefix: $++/path, $--/path, $!!/path (operator immediately after $ before path)
|
|
719
|
+
* - Postfix: $/path++ or $/path-- (operator at end of expression, not followed by ()
|
|
720
|
+
* - Infix with spaces: $/path + $/other (spaces around operator)
|
|
721
|
+
*/
|
|
722
|
+
const hasOperatorSyntax = (expr) => {
|
|
723
|
+
if (!expr || typeof expr !== 'string') return false;
|
|
724
|
+
|
|
725
|
+
// Skip function calls - they use legacy parser
|
|
726
|
+
if (expr.includes('(')) return false;
|
|
727
|
+
|
|
728
|
+
// Check for prefix operator pattern: $++ or $-- followed by /
|
|
729
|
+
// This catches: $++/counter, $--/value
|
|
730
|
+
if (/^\$(\+\+|--|!!)\/?/.test(expr)) {
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Check for postfix operator pattern: path ending with ++ or --
|
|
735
|
+
// This catches: $/counter++, $/value--
|
|
736
|
+
if (/(\+\+|--)$/.test(expr)) {
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Check for infix with explicit whitespace: $/a + $/b
|
|
741
|
+
// The spaces make it unambiguous that the symbol is an operator, not part of a path
|
|
742
|
+
if (/\s+([+\-*/]|>|<|>=|<=|!=)\s+/.test(expr)) {
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return false;
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Pratt Parser for JPRX expressions.
|
|
751
|
+
* Parses tokens into an AST respecting operator precedence.
|
|
752
|
+
*/
|
|
753
|
+
class PrattParser {
|
|
754
|
+
constructor(tokens, context, isGlobalMode = false) {
|
|
755
|
+
this.tokens = tokens;
|
|
756
|
+
this.pos = 0;
|
|
757
|
+
this.context = context;
|
|
758
|
+
this.isGlobalMode = isGlobalMode;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
peek() {
|
|
762
|
+
return this.tokens[this.pos] || { type: TokenType.EOF, value: null };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
consume() {
|
|
766
|
+
return this.tokens[this.pos++];
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
expect(type) {
|
|
770
|
+
const tok = this.consume();
|
|
771
|
+
if (tok.type !== type) {
|
|
772
|
+
throw new Error(`JPRX: Expected ${type} but got ${tok.type}`);
|
|
773
|
+
}
|
|
774
|
+
return tok;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Get binding power (precedence) for an infix or postfix operator.
|
|
779
|
+
*/
|
|
780
|
+
getInfixPrecedence(op) {
|
|
781
|
+
const infixInfo = operators.infix.get(op);
|
|
782
|
+
if (infixInfo) return infixInfo.precedence;
|
|
783
|
+
const postfixInfo = operators.postfix.get(op);
|
|
784
|
+
if (postfixInfo) return postfixInfo.precedence;
|
|
785
|
+
return 0;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Parse an expression with given minimum precedence.
|
|
790
|
+
*/
|
|
791
|
+
parseExpression(minPrecedence = 0) {
|
|
792
|
+
let left = this.parsePrefix();
|
|
793
|
+
let tok = this.peek();
|
|
794
|
+
|
|
795
|
+
while (tok.type === TokenType.OPERATOR) {
|
|
796
|
+
const prec = this.getInfixPrecedence(tok.value);
|
|
797
|
+
if (prec < minPrecedence) break;
|
|
798
|
+
|
|
799
|
+
// Check if it's a postfix operator
|
|
800
|
+
if (operators.postfix.has(tok.value) && !operators.infix.has(tok.value)) {
|
|
801
|
+
this.consume();
|
|
802
|
+
left = { type: 'Postfix', operator: tok.value, operand: left };
|
|
803
|
+
tok = this.peek();
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Check if it's an infix operator
|
|
808
|
+
if (operators.infix.has(tok.value)) {
|
|
809
|
+
this.consume();
|
|
810
|
+
// Right associativity would use prec, left uses prec + 1
|
|
811
|
+
const right = this.parseExpression(prec + 1);
|
|
812
|
+
left = { type: 'Infix', operator: tok.value, left, right };
|
|
813
|
+
tok = this.peek();
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Postfix that's also infix - context determines
|
|
818
|
+
// If next token is a value, treat as infix
|
|
819
|
+
this.consume();
|
|
820
|
+
const nextTok = this.peek();
|
|
821
|
+
if (nextTok.type === TokenType.PATH ||
|
|
822
|
+
nextTok.type === TokenType.LITERAL ||
|
|
823
|
+
nextTok.type === TokenType.LPAREN ||
|
|
824
|
+
nextTok.type === TokenType.PLACEHOLDER ||
|
|
825
|
+
nextTok.type === TokenType.EVENT ||
|
|
826
|
+
(nextTok.type === TokenType.OPERATOR && operators.prefix.has(nextTok.value))) {
|
|
827
|
+
// Infix
|
|
828
|
+
const right = this.parseExpression(prec + 1);
|
|
829
|
+
left = { type: 'Infix', operator: tok.value, left, right };
|
|
830
|
+
} else {
|
|
831
|
+
// Postfix
|
|
832
|
+
left = { type: 'Postfix', operator: tok.value, operand: left };
|
|
833
|
+
}
|
|
834
|
+
tok = this.peek();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return left;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Parse a prefix expression (literals, paths, prefix operators, groups).
|
|
842
|
+
*/
|
|
843
|
+
parsePrefix() {
|
|
844
|
+
const tok = this.peek();
|
|
845
|
+
|
|
846
|
+
// Prefix operator
|
|
847
|
+
if (tok.type === TokenType.OPERATOR && operators.prefix.has(tok.value)) {
|
|
848
|
+
this.consume();
|
|
849
|
+
const prefixInfo = operators.prefix.get(tok.value);
|
|
850
|
+
const operand = this.parseExpression(prefixInfo.precedence);
|
|
851
|
+
return { type: 'Prefix', operator: tok.value, operand };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Grouped expression
|
|
855
|
+
if (tok.type === TokenType.LPAREN) {
|
|
856
|
+
this.consume();
|
|
857
|
+
const inner = this.parseExpression(0);
|
|
858
|
+
this.expect(TokenType.RPAREN);
|
|
859
|
+
return inner;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Literal
|
|
863
|
+
if (tok.type === TokenType.LITERAL) {
|
|
864
|
+
this.consume();
|
|
865
|
+
return { type: 'Literal', value: tok.value };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Placeholder
|
|
869
|
+
if (tok.type === TokenType.PLACEHOLDER) {
|
|
870
|
+
this.consume();
|
|
871
|
+
return { type: 'Placeholder', value: tok.value };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Event
|
|
875
|
+
if (tok.type === TokenType.EVENT) {
|
|
876
|
+
this.consume();
|
|
877
|
+
return { type: 'Event', value: tok.value };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Path (possibly with explosion)
|
|
881
|
+
if (tok.type === TokenType.PATH) {
|
|
882
|
+
this.consume();
|
|
883
|
+
const nextTok = this.peek();
|
|
884
|
+
if (nextTok.type === TokenType.EXPLOSION) {
|
|
885
|
+
this.consume();
|
|
886
|
+
return { type: 'Explosion', path: tok.value };
|
|
887
|
+
}
|
|
888
|
+
return { type: 'Path', value: tok.value };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// EOF or unknown
|
|
892
|
+
if (tok.type === TokenType.EOF) {
|
|
893
|
+
return { type: 'Literal', value: undefined };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
throw new Error(`JPRX: Unexpected token ${tok.type}: ${tok.value}`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Evaluates a Pratt parser AST node into a value.
|
|
902
|
+
* @param {object} ast - The AST node
|
|
903
|
+
* @param {object} context - The evaluation context
|
|
904
|
+
* @param {boolean} forMutation - Whether to preserve BindingTarget for mutation
|
|
905
|
+
* @returns {any}
|
|
906
|
+
*/
|
|
907
|
+
const evaluateAST = (ast, context, forMutation = false) => {
|
|
908
|
+
if (!ast) return undefined;
|
|
909
|
+
|
|
910
|
+
switch (ast.type) {
|
|
911
|
+
case 'Literal':
|
|
912
|
+
return ast.value;
|
|
913
|
+
|
|
914
|
+
case 'Path': {
|
|
915
|
+
const resolved = forMutation
|
|
916
|
+
? resolvePathAsContext(ast.value, context)
|
|
917
|
+
: resolvePath(ast.value, context);
|
|
918
|
+
return forMutation ? resolved : unwrapSignal(resolved);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
case 'Placeholder': {
|
|
922
|
+
// Return a LazyValue for placeholder resolution
|
|
923
|
+
return new LazyValue((item) => {
|
|
924
|
+
if (ast.value === '_') return item;
|
|
925
|
+
const path = ast.value.startsWith('_.') ? ast.value.slice(2) : ast.value.slice(2);
|
|
926
|
+
return resolvePath(path, item);
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
case 'Event': {
|
|
931
|
+
return new LazyValue((event) => {
|
|
932
|
+
if (ast.value === '$event') return event;
|
|
933
|
+
const path = ast.value.startsWith('$event.') ? ast.value.slice(7) : ast.value.slice(7);
|
|
934
|
+
return resolvePath(path, event);
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
case 'Explosion': {
|
|
939
|
+
const result = resolveArgument(ast.path + '...', context, false);
|
|
940
|
+
return result.value;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
case 'Prefix': {
|
|
944
|
+
const opInfo = operators.prefix.get(ast.operator);
|
|
945
|
+
if (!opInfo) {
|
|
946
|
+
throw new Error(`JPRX: Unknown prefix operator: ${ast.operator}`);
|
|
947
|
+
}
|
|
948
|
+
const helper = helpers.get(opInfo.helper);
|
|
949
|
+
if (!helper) {
|
|
950
|
+
throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Check if helper needs BindingTarget (pathAware)
|
|
954
|
+
const opts = helperOptions.get(opInfo.helper) || {};
|
|
955
|
+
const operand = evaluateAST(ast.operand, context, opts.pathAware);
|
|
956
|
+
return helper(operand);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
case 'Postfix': {
|
|
960
|
+
const opInfo = operators.postfix.get(ast.operator);
|
|
961
|
+
if (!opInfo) {
|
|
962
|
+
throw new Error(`JPRX: Unknown postfix operator: ${ast.operator}`);
|
|
963
|
+
}
|
|
964
|
+
const helper = helpers.get(opInfo.helper);
|
|
965
|
+
if (!helper) {
|
|
966
|
+
throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const opts = helperOptions.get(opInfo.helper) || {};
|
|
970
|
+
const operand = evaluateAST(ast.operand, context, opts.pathAware);
|
|
971
|
+
return helper(operand);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
case 'Infix': {
|
|
975
|
+
const opInfo = operators.infix.get(ast.operator);
|
|
976
|
+
if (!opInfo) {
|
|
977
|
+
throw new Error(`JPRX: Unknown infix operator: ${ast.operator}`);
|
|
978
|
+
}
|
|
979
|
+
const helper = helpers.get(opInfo.helper);
|
|
980
|
+
if (!helper) {
|
|
981
|
+
throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const opts = helperOptions.get(opInfo.helper) || {};
|
|
985
|
+
// For infix, typically first arg might be pathAware
|
|
986
|
+
const left = evaluateAST(ast.left, context, opts.pathAware);
|
|
987
|
+
const right = evaluateAST(ast.right, context, false);
|
|
988
|
+
return helper(unwrapSignal(left), unwrapSignal(right));
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
default:
|
|
992
|
+
throw new Error(`JPRX: Unknown AST node type: ${ast.type}`);
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Parses and evaluates a JPRX expression using the Pratt parser.
|
|
998
|
+
* @param {string} expr - The expression string
|
|
999
|
+
* @param {object} context - The evaluation context
|
|
1000
|
+
* @returns {any}
|
|
1001
|
+
*/
|
|
1002
|
+
const parseWithPratt = (expr, context) => {
|
|
1003
|
+
const tokens = tokenize(expr);
|
|
1004
|
+
const parser = new PrattParser(tokens, context);
|
|
1005
|
+
const ast = parser.parseExpression(0);
|
|
1006
|
+
return evaluateAST(ast, context);
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Core logic to resolve an CDOM expression.
|
|
1012
|
+
* This can be called recursively and will register all accessed dependencies
|
|
1013
|
+
* against the currently active Lightview effect.
|
|
1014
|
+
*/
|
|
1015
|
+
export const resolveExpression = (expr, context) => {
|
|
1016
|
+
if (typeof expr !== 'string') return expr;
|
|
1017
|
+
|
|
1018
|
+
// Check if this expression uses operator syntax (prefix/postfix/infix operators)
|
|
1019
|
+
// If so, use the Pratt parser for proper precedence handling
|
|
1020
|
+
if (hasOperatorSyntax(expr)) {
|
|
1021
|
+
try {
|
|
1022
|
+
return parseWithPratt(expr, context);
|
|
1023
|
+
} catch (e) {
|
|
1024
|
+
// Fall back to legacy parsing if Pratt fails
|
|
1025
|
+
globalThis.console?.warn('JPRX: Pratt parser failed, falling back to legacy:', e.message);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const funcStart = expr.indexOf('(');
|
|
1030
|
+
if (funcStart !== -1 && expr.endsWith(')')) {
|
|
1031
|
+
const fullPath = expr.slice(0, funcStart).trim();
|
|
1032
|
+
const argsStr = expr.slice(funcStart + 1, -1);
|
|
1033
|
+
|
|
1034
|
+
const segments = fullPath.split('/');
|
|
1035
|
+
let funcName = segments.pop().replace(/^\$/, '');
|
|
1036
|
+
|
|
1037
|
+
// Handle case where path ends in / (like $/ for division helper)
|
|
1038
|
+
if (funcName === '' && (segments.length > 0 || fullPath === '/')) {
|
|
1039
|
+
funcName = '/';
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const navPath = segments.join('/');
|
|
1043
|
+
|
|
1044
|
+
const isGlobalExpr = expr.startsWith('$/') || expr.startsWith('$');
|
|
1045
|
+
|
|
1046
|
+
let baseContext = context;
|
|
1047
|
+
if (navPath && navPath !== '$') {
|
|
1048
|
+
baseContext = resolvePathAsContext(navPath, context);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const helper = helpers.get(funcName);
|
|
1052
|
+
if (!helper) {
|
|
1053
|
+
globalThis.console?.warn(`LightviewCDOM: Helper "${funcName}" not found.`);
|
|
1054
|
+
return expr;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const options = helperOptions.get(funcName) || {};
|
|
1058
|
+
|
|
1059
|
+
// Split arguments respecting quotes, parentheses, curly braces, and square brackets
|
|
1060
|
+
const argsList = [];
|
|
1061
|
+
let current = '', parenDepth = 0, braceDepth = 0, bracketDepth = 0, quote = null;
|
|
1062
|
+
for (let i = 0; i < argsStr.length; i++) {
|
|
1063
|
+
const char = argsStr[i];
|
|
1064
|
+
if (char === quote) quote = null;
|
|
1065
|
+
else if (!quote && (char === "'" || char === '"')) quote = char;
|
|
1066
|
+
else if (!quote && char === '(') parenDepth++;
|
|
1067
|
+
else if (!quote && char === ')') parenDepth--;
|
|
1068
|
+
else if (!quote && char === '{') braceDepth++;
|
|
1069
|
+
else if (!quote && char === '}') braceDepth--;
|
|
1070
|
+
else if (!quote && char === '[') bracketDepth++;
|
|
1071
|
+
else if (!quote && char === ']') bracketDepth--;
|
|
1072
|
+
else if (!quote && char === ',' && parenDepth === 0 && braceDepth === 0 && bracketDepth === 0) {
|
|
1073
|
+
argsList.push(current.trim());
|
|
1074
|
+
current = '';
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
current += char;
|
|
1078
|
+
}
|
|
1079
|
+
if (current) argsList.push(current.trim());
|
|
1080
|
+
|
|
1081
|
+
const resolvedArgs = [];
|
|
1082
|
+
let hasLazy = false;
|
|
1083
|
+
for (let i = 0; i < argsList.length; i++) {
|
|
1084
|
+
const arg = argsList[i];
|
|
1085
|
+
const useGlobalMode = isGlobalExpr && (navPath === '$' || !navPath);
|
|
1086
|
+
const res = resolveArgument(arg, baseContext, useGlobalMode);
|
|
1087
|
+
|
|
1088
|
+
if (res.isLazy) hasLazy = true;
|
|
1089
|
+
|
|
1090
|
+
// For mutation helpers, skip unwrapping for specific arguments (usually the first)
|
|
1091
|
+
const shouldUnwrap = !(options.pathAware && i === 0);
|
|
1092
|
+
|
|
1093
|
+
// Don't unwrap LazyValues - pass them directly to helpers
|
|
1094
|
+
// Helpers like map() need the LazyValue.resolve method
|
|
1095
|
+
let val = res.value;
|
|
1096
|
+
if (shouldUnwrap && !(val && val.isLazy)) {
|
|
1097
|
+
val = unwrapSignal(val);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (res.isExplosion && Array.isArray(val)) {
|
|
1101
|
+
resolvedArgs.push(...val.map(v => (shouldUnwrap && !(v && v.isLazy)) ? unwrapSignal(v) : v));
|
|
1102
|
+
} else {
|
|
1103
|
+
resolvedArgs.push(val);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (hasLazy && !options.lazyAware) {
|
|
1108
|
+
// Return a new LazyValue that resolves all its lazy arguments
|
|
1109
|
+
// Only for helpers that don't know how to handle LazyValue internally
|
|
1110
|
+
return new LazyValue((contextOverride) => {
|
|
1111
|
+
const finalArgs = resolvedArgs.map((arg, i) => {
|
|
1112
|
+
const shouldUnwrap = !(options.pathAware && i === 0);
|
|
1113
|
+
const resolved = arg instanceof LazyValue ? arg.resolve(contextOverride) : arg;
|
|
1114
|
+
return shouldUnwrap ? unwrapSignal(resolved) : resolved;
|
|
1115
|
+
});
|
|
1116
|
+
return helper(...finalArgs);
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const result = helper(...resolvedArgs);
|
|
1121
|
+
return unwrapSignal(result);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
return unwrapSignal(resolvePath(expr, context));
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Parses an CDOM expression into a reactive signal.
|
|
1129
|
+
*/
|
|
1130
|
+
export const parseExpression = (expr, context) => {
|
|
1131
|
+
const LV = getLV();
|
|
1132
|
+
if (!LV || typeof expr !== 'string') return expr;
|
|
1133
|
+
|
|
1134
|
+
return LV.computed(() => resolveExpression(expr, context));
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Parses CDOMC (Concise CDOM) content into a JSON object.
|
|
1141
|
+
* Supports unquoted keys/values and strictly avoids 'eval'.
|
|
1142
|
+
*/
|
|
1143
|
+
export const parseCDOMC = (input) => {
|
|
1144
|
+
let i = 0;
|
|
1145
|
+
const len = input.length;
|
|
1146
|
+
|
|
1147
|
+
const skipWhitespace = () => {
|
|
1148
|
+
while (i < len) {
|
|
1149
|
+
const char = input[i];
|
|
1150
|
+
|
|
1151
|
+
// Standard whitespace
|
|
1152
|
+
if (/\s/.test(char)) {
|
|
1153
|
+
i++;
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Comments
|
|
1158
|
+
if (char === '/') {
|
|
1159
|
+
const next = input[i + 1];
|
|
1160
|
+
if (next === '/') {
|
|
1161
|
+
// Single-line comment
|
|
1162
|
+
i += 2;
|
|
1163
|
+
while (i < len && input[i] !== '\n' && input[i] !== '\r') i++;
|
|
1164
|
+
continue;
|
|
1165
|
+
} else if (next === '*') {
|
|
1166
|
+
// Multi-line comment (non-nested)
|
|
1167
|
+
i += 2;
|
|
1168
|
+
while (i < len) {
|
|
1169
|
+
if (input[i] === '*' && input[i + 1] === '/') {
|
|
1170
|
+
i += 2;
|
|
1171
|
+
break;
|
|
1172
|
+
}
|
|
1173
|
+
i++;
|
|
1174
|
+
}
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
break;
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
const parseString = () => {
|
|
1184
|
+
const quote = input[i++];
|
|
1185
|
+
let res = '';
|
|
1186
|
+
while (i < len) {
|
|
1187
|
+
const char = input[i++];
|
|
1188
|
+
if (char === quote) return res;
|
|
1189
|
+
if (char === '\\') {
|
|
1190
|
+
const next = input[i++];
|
|
1191
|
+
if (next === 'n') res += '\n';
|
|
1192
|
+
else if (next === 't') res += '\t';
|
|
1193
|
+
else if (next === '"') res += '"';
|
|
1194
|
+
else if (next === "'") res += "'";
|
|
1195
|
+
else if (next === '\\') res += '\\';
|
|
1196
|
+
else res += next;
|
|
1197
|
+
} else {
|
|
1198
|
+
res += char;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
throw new Error("Unterminated string");
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Parses an unquoted word (identifier, path, or literal).
|
|
1206
|
+
* Supports dashes in identifiers (e.g. cdom-state).
|
|
1207
|
+
* Words starting with $ are preserved as strings for cDOM expression parsing.
|
|
1208
|
+
*/
|
|
1209
|
+
const parseWord = () => {
|
|
1210
|
+
const start = i;
|
|
1211
|
+
let pDepth = 0;
|
|
1212
|
+
let bDepth = 0;
|
|
1213
|
+
let brDepth = 0;
|
|
1214
|
+
let quote = null;
|
|
1215
|
+
|
|
1216
|
+
while (i < len) {
|
|
1217
|
+
const char = input[i];
|
|
1218
|
+
|
|
1219
|
+
if (quote) {
|
|
1220
|
+
if (char === quote) quote = null;
|
|
1221
|
+
i++;
|
|
1222
|
+
continue;
|
|
1223
|
+
} else if (char === '"' || char === "'" || char === "`") {
|
|
1224
|
+
quote = char;
|
|
1225
|
+
i++;
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// Nesting
|
|
1230
|
+
if (char === '(') { pDepth++; i++; continue; }
|
|
1231
|
+
if (char === '{') { bDepth++; i++; continue; }
|
|
1232
|
+
if (char === '[') { brDepth++; i++; continue; }
|
|
1233
|
+
|
|
1234
|
+
if (char === ')') { if (pDepth > 0) { pDepth--; i++; continue; } }
|
|
1235
|
+
if (char === '}') { if (bDepth > 0) { bDepth--; i++; continue; } }
|
|
1236
|
+
if (char === ']') { if (brDepth > 0) { brDepth--; i++; continue; } }
|
|
1237
|
+
|
|
1238
|
+
// Termination at depth 0
|
|
1239
|
+
if (pDepth === 0 && bDepth === 0 && brDepth === 0) {
|
|
1240
|
+
if (/[\s:,{}\[\]"'`()]/.test(char)) {
|
|
1241
|
+
break;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
i++;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const word = input.slice(start, i);
|
|
1249
|
+
|
|
1250
|
+
// If word starts with $, preserve it as a string for cDOM expression parsing
|
|
1251
|
+
if (word.startsWith('$')) {
|
|
1252
|
+
return word;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (word === 'true') return true;
|
|
1256
|
+
if (word === 'false') return false;
|
|
1257
|
+
if (word === 'null') return null;
|
|
1258
|
+
// Check if valid number
|
|
1259
|
+
if (word.trim() !== '' && !isNaN(Number(word))) return Number(word);
|
|
1260
|
+
return word;
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
const parseValue = () => {
|
|
1264
|
+
skipWhitespace();
|
|
1265
|
+
if (i >= len) return undefined;
|
|
1266
|
+
const char = input[i];
|
|
1267
|
+
|
|
1268
|
+
if (char === '{') return parseObject();
|
|
1269
|
+
if (char === '[') return parseArray();
|
|
1270
|
+
if (char === '"' || char === "'") return parseString();
|
|
1271
|
+
|
|
1272
|
+
return parseWord();
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
const parseObject = () => {
|
|
1276
|
+
i++; // skip '{'
|
|
1277
|
+
const obj = {};
|
|
1278
|
+
skipWhitespace();
|
|
1279
|
+
if (i < len && input[i] === '}') {
|
|
1280
|
+
i++;
|
|
1281
|
+
return obj;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
while (i < len) {
|
|
1285
|
+
skipWhitespace();
|
|
1286
|
+
let key;
|
|
1287
|
+
if (input[i] === '"' || input[i] === "'") key = parseString();
|
|
1288
|
+
else key = parseWord(); // No longer need special key handling
|
|
1289
|
+
|
|
1290
|
+
skipWhitespace();
|
|
1291
|
+
if (input[i] !== ':') throw new Error(`Expected ':' at position ${i}, found '${input[i]}'`);
|
|
1292
|
+
i++; // skip ':'
|
|
1293
|
+
|
|
1294
|
+
const value = parseValue();
|
|
1295
|
+
obj[String(key)] = value;
|
|
1296
|
+
|
|
1297
|
+
skipWhitespace();
|
|
1298
|
+
if (input[i] === '}') {
|
|
1299
|
+
i++;
|
|
1300
|
+
return obj;
|
|
1301
|
+
}
|
|
1302
|
+
if (input[i] === ',') {
|
|
1303
|
+
i++;
|
|
1304
|
+
skipWhitespace();
|
|
1305
|
+
if (input[i] === '}') {
|
|
1306
|
+
i++;
|
|
1307
|
+
return obj;
|
|
1308
|
+
}
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
throw new Error(`Expected '}' or ',' at position ${i}, found '${input[i]}'`);
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1315
|
+
const parseArray = () => {
|
|
1316
|
+
i++; // skip '['
|
|
1317
|
+
const arr = [];
|
|
1318
|
+
skipWhitespace();
|
|
1319
|
+
if (i < len && input[i] === ']') {
|
|
1320
|
+
i++;
|
|
1321
|
+
return arr;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
while (i < len) {
|
|
1325
|
+
const val = parseValue();
|
|
1326
|
+
arr.push(val);
|
|
1327
|
+
|
|
1328
|
+
skipWhitespace();
|
|
1329
|
+
if (input[i] === ']') {
|
|
1330
|
+
i++;
|
|
1331
|
+
return arr;
|
|
1332
|
+
}
|
|
1333
|
+
if (input[i] === ',') {
|
|
1334
|
+
i++;
|
|
1335
|
+
skipWhitespace();
|
|
1336
|
+
if (input[i] === ']') {
|
|
1337
|
+
i++;
|
|
1338
|
+
return arr;
|
|
1339
|
+
}
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
throw new Error(`Expected ']' or ',' at position ${i}, found '${input[i]}'`);
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1346
|
+
skipWhitespace();
|
|
1347
|
+
const res = parseValue();
|
|
1348
|
+
return res;
|
|
1349
|
+
};
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* JPRXC Preprocessor: Converts concise JPRX format to valid JSON.
|
|
1353
|
+
*
|
|
1354
|
+
* JPRXC allows:
|
|
1355
|
+
* - Unquoted property names (tag, children, onclick)
|
|
1356
|
+
* - Unquoted JPRX expressions ($++/counter, $/path)
|
|
1357
|
+
* - Single-line and multi-line comments
|
|
1358
|
+
*
|
|
1359
|
+
* This preprocessor transforms JPRXC to JSON string, then uses native JSON.parse.
|
|
1360
|
+
*
|
|
1361
|
+
* @param {string} input - JPRXC content
|
|
1362
|
+
* @returns {object} - Parsed JSON object
|
|
1363
|
+
*/
|
|
1364
|
+
export const parseJPRX = (input) => {
|
|
1365
|
+
let result = '';
|
|
1366
|
+
let i = 0;
|
|
1367
|
+
const len = input.length;
|
|
1368
|
+
|
|
1369
|
+
while (i < len) {
|
|
1370
|
+
const char = input[i];
|
|
1371
|
+
|
|
1372
|
+
// Handle // single-line comments
|
|
1373
|
+
if (char === '/' && input[i + 1] === '/') {
|
|
1374
|
+
while (i < len && input[i] !== '\n') i++;
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// Handle /* multi-line comments */
|
|
1379
|
+
if (char === '/' && input[i + 1] === '*') {
|
|
1380
|
+
i += 2;
|
|
1381
|
+
while (i < len && !(input[i] === '*' && input[i + 1] === '/')) i++;
|
|
1382
|
+
i += 2; // skip */
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// Handle quoted strings
|
|
1387
|
+
if (char === '"' || char === "'") {
|
|
1388
|
+
const quote = char;
|
|
1389
|
+
result += '"'; // Start double quoted string
|
|
1390
|
+
i++; // skip opening quote
|
|
1391
|
+
|
|
1392
|
+
while (i < len && input[i] !== quote) {
|
|
1393
|
+
const c = input[i];
|
|
1394
|
+
if (c === '\\') {
|
|
1395
|
+
// Handle existing specific escapes
|
|
1396
|
+
result += '\\'; // Preserved backslash
|
|
1397
|
+
i++;
|
|
1398
|
+
if (i < len) {
|
|
1399
|
+
const next = input[i];
|
|
1400
|
+
// If it's a quote that matches our output quote ("), we need to ensure it's escaped
|
|
1401
|
+
if (next === '"') result += '\\"';
|
|
1402
|
+
else result += next;
|
|
1403
|
+
i++;
|
|
1404
|
+
}
|
|
1405
|
+
} else if (c === '"') {
|
|
1406
|
+
result += '\\"'; // Escape double quotes since we're outputting "
|
|
1407
|
+
i++;
|
|
1408
|
+
} else if (c === '\n') {
|
|
1409
|
+
result += '\\n';
|
|
1410
|
+
i++;
|
|
1411
|
+
} else if (c === '\r') {
|
|
1412
|
+
result += '\\r';
|
|
1413
|
+
i++;
|
|
1414
|
+
} else if (c === '\t') {
|
|
1415
|
+
result += '\\t';
|
|
1416
|
+
i++;
|
|
1417
|
+
} else {
|
|
1418
|
+
result += c;
|
|
1419
|
+
i++;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
result += '"'; // End double quoted string
|
|
1423
|
+
i++; // skip closing quote
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// Handle JPRX expressions starting with $ (MUST come before word handler!)
|
|
1428
|
+
if (char === '$') {
|
|
1429
|
+
let expr = '';
|
|
1430
|
+
let parenDepth = 0;
|
|
1431
|
+
let braceDepth = 0;
|
|
1432
|
+
let bracketDepth = 0;
|
|
1433
|
+
let inExprQuote = null;
|
|
1434
|
+
|
|
1435
|
+
while (i < len) {
|
|
1436
|
+
const c = input[i];
|
|
1437
|
+
|
|
1438
|
+
if (inExprQuote) {
|
|
1439
|
+
if (c === inExprQuote && input[i - 1] !== '\\') inExprQuote = null;
|
|
1440
|
+
} else if (c === '"' || c === "'") {
|
|
1441
|
+
inExprQuote = c;
|
|
1442
|
+
} else {
|
|
1443
|
+
// Check for break BEFORE updating depth
|
|
1444
|
+
if (parenDepth === 0 && braceDepth === 0 && bracketDepth === 0) {
|
|
1445
|
+
if (/[\s,}\]:]/.test(c) && expr.length > 1) break;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
if (c === '(') parenDepth++;
|
|
1449
|
+
else if (c === ')') parenDepth--;
|
|
1450
|
+
else if (c === '{') braceDepth++;
|
|
1451
|
+
else if (c === '}') braceDepth--;
|
|
1452
|
+
else if (c === '[') bracketDepth++;
|
|
1453
|
+
else if (c === ']') bracketDepth--;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
expr += c;
|
|
1457
|
+
i++;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// Use JSON.stringify to safely quote and escape the expression
|
|
1461
|
+
result += JSON.stringify(expr);
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// Handle unquoted property names, identifiers, and paths
|
|
1466
|
+
if (/[a-zA-Z_./]/.test(char)) {
|
|
1467
|
+
let word = '';
|
|
1468
|
+
while (i < len && /[a-zA-Z0-9_$/.-]/.test(input[i])) {
|
|
1469
|
+
word += input[i];
|
|
1470
|
+
i++;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Skip whitespace to check for :
|
|
1474
|
+
let j = i;
|
|
1475
|
+
while (j < len && /\s/.test(input[j])) j++;
|
|
1476
|
+
|
|
1477
|
+
if (input[j] === ':') {
|
|
1478
|
+
// It's a property name - quote it
|
|
1479
|
+
result += `"${word}"`;
|
|
1480
|
+
} else {
|
|
1481
|
+
// It's a value - check if it's a keyword
|
|
1482
|
+
if (word === 'true' || word === 'false' || word === 'null') {
|
|
1483
|
+
result += word;
|
|
1484
|
+
} else if (!isNaN(Number(word))) {
|
|
1485
|
+
result += word;
|
|
1486
|
+
} else {
|
|
1487
|
+
// Quote as string value
|
|
1488
|
+
result += `"${word}"`;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// Handle numbers
|
|
1495
|
+
if (/[\d]/.test(char) || (char === '-' && /\d/.test(input[i + 1]))) {
|
|
1496
|
+
let num = '';
|
|
1497
|
+
while (i < len && /[\d.\-eE]/.test(input[i])) {
|
|
1498
|
+
num += input[i];
|
|
1499
|
+
i++;
|
|
1500
|
+
}
|
|
1501
|
+
result += num;
|
|
1502
|
+
continue;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// Pass through structural characters and whitespace
|
|
1506
|
+
result += char;
|
|
1507
|
+
i++;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
try {
|
|
1511
|
+
return JSON.parse(result);
|
|
1512
|
+
} catch (e) {
|
|
1513
|
+
globalThis.console?.error('parseJPRX: JSON parse failed', e);
|
|
1514
|
+
globalThis.console?.error('Transformed input:', result);
|
|
1515
|
+
throw e;
|
|
1516
|
+
}
|
|
1517
|
+
};
|