@ender672/minja-js 0.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/src/minja.js ADDED
@@ -0,0 +1,2486 @@
1
+ // Core Minja template engine - JavaScript port of minja.hpp
2
+
3
+ // ── Value class ─────────────────────────────────────────────────────────
4
+
5
+ class Value {
6
+ constructor() {
7
+ this._array = null;
8
+ this._object = null;
9
+ this._callable = null;
10
+ this._primitive = null; // null means "null value"; otherwise {type, value}
11
+ }
12
+
13
+ static fromPrimitive(v) {
14
+ const val = new Value();
15
+ val._primitive = v;
16
+ return val;
17
+ }
18
+ static fromArray(arr) {
19
+ const val = new Value();
20
+ val._array = arr;
21
+ return val;
22
+ }
23
+ static fromObject(obj) {
24
+ const val = new Value();
25
+ val._object = obj;
26
+ return val;
27
+ }
28
+ static fromCallable(fn) {
29
+ const val = new Value();
30
+ val._object = new Map();
31
+ val._callable = fn;
32
+ return val;
33
+ }
34
+
35
+ static fromJS(v) {
36
+ if (v instanceof Value) return v;
37
+ if (v === null || v === undefined) return new Value();
38
+ if (typeof v === 'boolean') return Value.fromPrimitive({ type: 'bool', value: v });
39
+ if (typeof v === 'number') {
40
+ if (Number.isInteger(v)) return Value.fromPrimitive({ type: 'int', value: v });
41
+ return Value.fromPrimitive({ type: 'float', value: v });
42
+ }
43
+ if (typeof v === 'string') return Value.fromPrimitive({ type: 'string', value: v });
44
+ if (typeof v === 'function') return Value.fromCallable(v);
45
+ if (Array.isArray(v)) {
46
+ return Value.fromArray(v.map(item => Value.fromJS(item)));
47
+ }
48
+ if (typeof v === 'object') {
49
+ const map = new Map();
50
+ for (const [key, val] of Object.entries(v)) {
51
+ map.set(key, Value.fromJS(val));
52
+ }
53
+ return Value.fromObject(map);
54
+ }
55
+ return new Value();
56
+ }
57
+
58
+ static array(values = []) {
59
+ return Value.fromArray([...values]);
60
+ }
61
+ static object() {
62
+ return Value.fromObject(new Map());
63
+ }
64
+ static callable(fn) {
65
+ return Value.fromCallable(fn);
66
+ }
67
+
68
+ // Type checks
69
+ isNull() { return !this._array && !this._object && !this._callable && this._primitive === null; }
70
+ isBoolean() { return this._primitive !== null && this._primitive?.type === 'bool'; }
71
+ isNumberInteger() { return this._primitive !== null && this._primitive?.type === 'int'; }
72
+ isNumberFloat() { return this._primitive !== null && this._primitive?.type === 'float'; }
73
+ isNumber() { return this.isNumberInteger() || this.isNumberFloat(); }
74
+ isString() { return this._primitive !== null && this._primitive?.type === 'string'; }
75
+ isArray() { return this._array !== null; }
76
+ isObject() { return this._object !== null; }
77
+ isCallable() { return this._callable !== null; }
78
+ isIterable() { return this.isArray() || this.isObject() || this.isString(); }
79
+ isPrimitive() { return !this._array && !this._object && !this._callable; }
80
+ isHashable() { return this.isPrimitive(); }
81
+
82
+ get(key) {
83
+ if (key instanceof Value) {
84
+ if (this._array) {
85
+ if (!key.isNumberInteger()) return new Value();
86
+ let idx = key.value;
87
+ if (idx < 0) idx += this._array.length;
88
+ if (idx < 0 || idx >= this._array.length) {
89
+ throw new RangeError(`array index out of range: index ${key.value}, size ${this._array.length}`);
90
+ }
91
+ return this._array[idx];
92
+ }
93
+ if (this._object) {
94
+ if (!key.isHashable()) throw new Error('Unhashable type: ' + this.dump());
95
+ const k = key.isString() ? key.value : String(key.value ?? 'null');
96
+ if (this._object.has(k)) return this._object.get(k);
97
+ return new Value();
98
+ }
99
+ return new Value();
100
+ }
101
+ // String key lookup
102
+ if (this._object) {
103
+ if (this._object.has(key)) return this._object.get(key);
104
+ return new Value();
105
+ }
106
+ return new Value();
107
+ }
108
+
109
+ set(key, value) {
110
+ if (!this._object) throw new Error('Value is not an object: ' + this.dump());
111
+ const v = value instanceof Value ? value : Value.fromJS(value);
112
+ if (key instanceof Value) {
113
+ if (!key.isHashable()) throw new Error('Unhashable type: ' + this.dump());
114
+ const k = key.isString() ? key.value : String(key.value ?? 'null');
115
+ this._object.set(k, v);
116
+ if (!this._keyValues) this._keyValues = new Map();
117
+ this._keyValues.set(k, key);
118
+ } else {
119
+ this._object.set(key, v);
120
+ }
121
+ }
122
+
123
+ get value() {
124
+ if (this._primitive === null) return null;
125
+ return this._primitive.value;
126
+ }
127
+
128
+ get size() {
129
+ if (this.isObject()) return this._object.size;
130
+ if (this.isArray()) return this._array.length;
131
+ if (this.isString()) return this._primitive.value.length;
132
+ throw new Error('Value is not an array or object: ' + this.dump());
133
+ }
134
+
135
+ contains(keyOrValue) {
136
+ if (keyOrValue instanceof Value) {
137
+ if (this.isNull()) throw new Error('Undefined value or reference');
138
+ if (this._array) {
139
+ for (const item of this._array) {
140
+ if (item.toBool() && item.eq(keyOrValue)) return true;
141
+ }
142
+ return false;
143
+ }
144
+ if (this._object) {
145
+ if (!keyOrValue.isHashable()) throw new Error('Unhashable type: ' + keyOrValue.dump());
146
+ const k = keyOrValue.isString() ? keyOrValue.value : String(keyOrValue.value ?? 'null');
147
+ return this._object.has(k);
148
+ }
149
+ throw new Error('contains can only be called on arrays and objects: ' + this.dump());
150
+ }
151
+ // String key
152
+ if (this._array) return false;
153
+ if (this._object) return this._object.has(keyOrValue);
154
+ throw new Error('contains can only be called on arrays and objects: ' + this.dump());
155
+ }
156
+
157
+ at(index) {
158
+ if (index instanceof Value) {
159
+ if (!index.isHashable()) throw new Error('Unhashable type: ' + this.dump());
160
+ if (this.isArray()) {
161
+ let idx = index.value;
162
+ if (typeof idx !== 'number') throw new Error('Array index must be a number');
163
+ if (idx < 0) idx += this._array.length;
164
+ if (idx < 0 || idx >= this._array.length) throw new Error('Index out of range');
165
+ return this._array[idx];
166
+ }
167
+ if (this.isObject()) {
168
+ const k = index.isString() ? index.value : String(index.value ?? 'null');
169
+ if (!this._object.has(k)) throw new Error('Key not found: ' + k);
170
+ return this._object.get(k);
171
+ }
172
+ throw new Error('Value is not an array or object: ' + this.dump());
173
+ }
174
+ // Numeric index
175
+ if (this.isNull()) throw new Error('Undefined value or reference');
176
+ if (this.isArray()) {
177
+ if (index < 0) index += this._array.length;
178
+ return this._array[index];
179
+ }
180
+ if (this.isObject()) {
181
+ const k = String(index);
182
+ if (!this._object.has(k)) throw new Error('Key not found: ' + k);
183
+ return this._object.get(k);
184
+ }
185
+ throw new Error('Value is not an array or object: ' + this.dump());
186
+ }
187
+
188
+ keys() {
189
+ if (!this._object) throw new Error('Value is not an object: ' + this.dump());
190
+ const res = [];
191
+ for (const key of this._object.keys()) {
192
+ if (this._keyValues && this._keyValues.has(key)) {
193
+ res.push(this._keyValues.get(key));
194
+ } else {
195
+ res.push(Value.fromJS(key));
196
+ }
197
+ }
198
+ return res;
199
+ }
200
+
201
+ pushBack(v) {
202
+ if (!this._array) throw new Error('Value is not an array: ' + this.dump());
203
+ this._array.push(v instanceof Value ? v : Value.fromJS(v));
204
+ }
205
+
206
+ insert(index, v) {
207
+ if (!this._array) throw new Error('Value is not an array: ' + this.dump());
208
+ this._array.splice(index, 0, v instanceof Value ? v : Value.fromJS(v));
209
+ }
210
+
211
+ pop(index) {
212
+ if (this.isArray()) {
213
+ if (this._array.length === 0) throw new Error('pop from empty list');
214
+ if (index === undefined || (index instanceof Value && index.isNull())) {
215
+ return this._array.pop();
216
+ }
217
+ const idx = index instanceof Value ? index.value : index;
218
+ if (typeof idx !== 'number' || !Number.isInteger(idx)) {
219
+ throw new Error('pop index must be an integer: ' + (index instanceof Value ? index.dump() : String(index)));
220
+ }
221
+ if (idx < 0 || idx >= this._array.length) {
222
+ throw new Error('pop index out of range: ' + (index instanceof Value ? index.dump() : String(index)));
223
+ }
224
+ const ret = this._array[idx];
225
+ this._array.splice(idx, 1);
226
+ return ret;
227
+ }
228
+ if (this.isObject()) {
229
+ if (index instanceof Value) {
230
+ if (!index.isHashable()) throw new Error('Unhashable type: ' + index.dump());
231
+ const k = index.isString() ? index.value : String(index.value ?? 'null');
232
+ if (!this._object.has(k)) throw new Error('Key not found: ' + index.dump());
233
+ const ret = this._object.get(k);
234
+ this._object.delete(k);
235
+ return ret;
236
+ }
237
+ if (!this._object.has(index)) throw new Error("Key not found: '" + index + "'");
238
+ const ret = this._object.get(index);
239
+ this._object.delete(index);
240
+ return ret;
241
+ }
242
+ throw new Error('Value is not an array or object: ' + this.dump());
243
+ }
244
+
245
+ empty() {
246
+ if (this.isNull()) throw new Error('Undefined value or reference');
247
+ if (this.isString()) return this._primitive.value.length === 0;
248
+ if (this.isArray()) return this._array.length === 0;
249
+ if (this.isObject()) return this._object.size === 0;
250
+ return false;
251
+ }
252
+
253
+ forEach(callback) {
254
+ if (this.isNull()) throw new Error('Undefined value or reference');
255
+ if (this._array) {
256
+ for (const item of this._array) callback(item);
257
+ } else if (this._object) {
258
+ for (const key of this._object.keys()) {
259
+ if (this._keyValues && this._keyValues.has(key)) callback(this._keyValues.get(key));
260
+ else callback(Value.fromJS(key));
261
+ }
262
+ } else if (this.isString()) {
263
+ for (const c of this._primitive.value) callback(Value.fromJS(c));
264
+ } else {
265
+ throw new Error('Value is not iterable: ' + this.dump());
266
+ }
267
+ }
268
+
269
+ toBool() {
270
+ if (this.isNull()) return false;
271
+ if (this.isBoolean()) return this._primitive.value;
272
+ if (this.isNumber()) return this._primitive.value !== 0;
273
+ if (this.isString()) return this._primitive.value.length > 0;
274
+ if (this.isArray()) return !this.empty();
275
+ return true;
276
+ }
277
+
278
+ toInt() {
279
+ if (this.isNull()) return 0;
280
+ if (this.isBoolean()) return this._primitive.value ? 1 : 0;
281
+ if (this.isNumber()) return Math.trunc(this._primitive.value);
282
+ if (this.isString()) {
283
+ const n = parseInt(this._primitive.value, 10);
284
+ return isNaN(n) ? 0 : n;
285
+ }
286
+ return 0;
287
+ }
288
+
289
+ toStr() {
290
+ if (this.isString()) return this._primitive.value;
291
+ if (this.isNumberInteger()) return String(this._primitive.value);
292
+ if (this.isNumberFloat()) return this._primitive.value.toFixed(6);
293
+ if (this.isBoolean()) return this._primitive.value ? 'True' : 'False';
294
+ if (this.isNull()) return 'None';
295
+ return this.dump();
296
+ }
297
+
298
+ neg() {
299
+ if (this.isNumberInteger()) return Value.fromPrimitive({ type: 'int', value: -this._primitive.value });
300
+ return Value.fromPrimitive({ type: 'float', value: -this._primitive.value });
301
+ }
302
+
303
+ add(rhs) {
304
+ if (this.isString() || rhs.isString()) {
305
+ return Value.fromJS(this.toStr() + rhs.toStr());
306
+ }
307
+ if (this.isNumberInteger() && rhs.isNumberInteger()) {
308
+ return Value.fromPrimitive({ type: 'int', value: this._primitive.value + rhs._primitive.value });
309
+ }
310
+ if (this.isArray() && rhs.isArray()) {
311
+ const res = Value.array();
312
+ for (const item of this._array) res.pushBack(item);
313
+ for (const item of rhs._array) res.pushBack(item);
314
+ return res;
315
+ }
316
+ return Value.fromPrimitive({ type: 'float', value: this.getNumber() + rhs.getNumber() });
317
+ }
318
+
319
+ sub(rhs) {
320
+ if (this.isNumberInteger() && rhs.isNumberInteger()) {
321
+ return Value.fromPrimitive({ type: 'int', value: this._primitive.value - rhs._primitive.value });
322
+ }
323
+ return Value.fromPrimitive({ type: 'float', value: this.getNumber() - rhs.getNumber() });
324
+ }
325
+
326
+ mul(rhs) {
327
+ if (this.isString() && rhs.isNumberInteger()) {
328
+ const n = rhs._primitive.value;
329
+ return Value.fromJS(n > 0 ? this._primitive.value.repeat(n) : '');
330
+ }
331
+ if (this.isNumberInteger() && rhs.isNumberInteger()) {
332
+ return Value.fromPrimitive({ type: 'int', value: this._primitive.value * rhs._primitive.value });
333
+ }
334
+ return Value.fromPrimitive({ type: 'float', value: this.getNumber() * rhs.getNumber() });
335
+ }
336
+
337
+ div(rhs) {
338
+ if (this.isNumberInteger() && rhs.isNumberInteger()) {
339
+ const a = this._primitive.value;
340
+ const b = rhs._primitive.value;
341
+ return Value.fromPrimitive({ type: 'int', value: Math.trunc(a / b) });
342
+ }
343
+ return Value.fromPrimitive({ type: 'float', value: this.getNumber() / rhs.getNumber() });
344
+ }
345
+
346
+ mod(rhs) {
347
+ return Value.fromPrimitive({ type: 'int', value: this.getInt() % rhs.getInt() });
348
+ }
349
+
350
+ getNumber() {
351
+ if (this._primitive === null || (this._primitive.type !== 'int' && this._primitive.type !== 'float')) {
352
+ throw new Error('Value is not a number: ' + this.dump());
353
+ }
354
+ return this._primitive.value;
355
+ }
356
+
357
+ getInt() {
358
+ if (this._primitive === null || (this._primitive.type !== 'int' && this._primitive.type !== 'float')) {
359
+ throw new Error('Value is not a number: ' + this.dump());
360
+ }
361
+ return Math.trunc(this._primitive.value);
362
+ }
363
+
364
+ eq(other) {
365
+ if (this._callable || other._callable) {
366
+ return this._callable === other._callable;
367
+ }
368
+ if (this._array) {
369
+ if (!other._array) return false;
370
+ if (this._array.length !== other._array.length) return false;
371
+ for (let i = 0; i < this._array.length; i++) {
372
+ if (!this._array[i].toBool() || !other._array[i].toBool() || !this._array[i].eq(other._array[i])) return false;
373
+ }
374
+ return true;
375
+ }
376
+ if (this._object && !this._callable) {
377
+ if (!other._object) return false;
378
+ if (this._object.size !== other._object.size) return false;
379
+ for (const [key, val] of this._object) {
380
+ if (!val.toBool() || !other._object.has(key) || !val.eq(other._object.get(key))) return false;
381
+ }
382
+ return true;
383
+ }
384
+ // Primitive comparison
385
+ if (this.isNull() && other.isNull()) return true;
386
+ if (this.isNull() || other.isNull()) return false;
387
+ if (this._primitive?.type === other._primitive?.type) return this._primitive.value === other._primitive.value;
388
+ // Cross-type: both numbers
389
+ if (this.isNumber() && other.isNumber()) return this._primitive.value === other._primitive.value;
390
+ return false;
391
+ }
392
+
393
+ ne(other) { return !this.eq(other); }
394
+
395
+ lt(other) {
396
+ if (this.isNull()) throw new Error('Undefined value or reference');
397
+ if (this.isNumber() && other.isNumber()) return this._primitive.value < other._primitive.value;
398
+ if (this.isString() && other.isString()) return this._primitive.value < other._primitive.value;
399
+ throw new Error('Cannot compare values: ' + this.dump() + ' < ' + other.dump());
400
+ }
401
+
402
+ gt(other) {
403
+ if (this.isNull()) throw new Error('Undefined value or reference');
404
+ if (this.isNumber() && other.isNumber()) return this._primitive.value > other._primitive.value;
405
+ if (this.isString() && other.isString()) return this._primitive.value > other._primitive.value;
406
+ throw new Error('Cannot compare values: ' + this.dump() + ' > ' + other.dump());
407
+ }
408
+
409
+ le(other) { return !this.gt(other); }
410
+ ge(other) { return !this.lt(other); }
411
+
412
+ call(context, args) {
413
+ if (!this._callable) throw new Error('Value is not callable: ' + this.dump());
414
+ return this._callable(context, args);
415
+ }
416
+
417
+ // Dump with Python-style repr or JSON
418
+ static _dumpString(str, quote = "'") {
419
+ const jsonStr = JSON.stringify(str);
420
+ if (quote === '"' || str.includes("'")) {
421
+ return jsonStr;
422
+ }
423
+ let result = quote;
424
+ for (let i = 1; i < jsonStr.length - 1; i++) {
425
+ if (jsonStr[i] === '\\' && jsonStr[i + 1] === '"') {
426
+ result += '"';
427
+ i++;
428
+ } else if (jsonStr[i] === quote) {
429
+ result += '\\' + quote;
430
+ } else {
431
+ result += jsonStr[i];
432
+ }
433
+ }
434
+ result += quote;
435
+ return result;
436
+ }
437
+
438
+ dump(indent = -1, toJson = false) {
439
+ return this._dumpImpl(indent, 0, toJson);
440
+ }
441
+
442
+ _dumpImpl(indent, level, toJson) {
443
+ const printIndent = (lvl) => {
444
+ if (indent > 0) return '\n' + ' '.repeat(lvl * indent);
445
+ return '';
446
+ };
447
+ const subSep = () => {
448
+ if (indent < 0) return ', ';
449
+ return ',' + printIndent(level + 1);
450
+ };
451
+
452
+ const stringQuote = toJson ? '"' : "'";
453
+
454
+ if (this.isNull()) return 'null';
455
+ if (this._array) {
456
+ let out = '[';
457
+ out += printIndent(level + 1);
458
+ for (let i = 0; i < this._array.length; i++) {
459
+ if (i > 0) out += subSep();
460
+ out += this._array[i]._dumpImpl(indent, level + 1, toJson);
461
+ }
462
+ out += printIndent(level);
463
+ out += ']';
464
+ return out;
465
+ }
466
+ if (this._object && !this._callable) {
467
+ let out = '{';
468
+ out += printIndent(level + 1);
469
+ let first = true;
470
+ for (const [key, value] of this._object) {
471
+ if (!first) out += subSep();
472
+ first = false;
473
+ // Use original key Value type if available (preserves int keys in non-JSON mode)
474
+ const keyVal = this._keyValues && this._keyValues.has(key) ? this._keyValues.get(key) : null;
475
+ if (keyVal && !keyVal.isString() && !toJson) {
476
+ out += keyVal._dumpImpl(indent, level + 1, toJson);
477
+ } else {
478
+ out += Value._dumpString(key, stringQuote);
479
+ }
480
+ out += ': ';
481
+ out += value._dumpImpl(indent, level + 1, toJson);
482
+ }
483
+ out += printIndent(level);
484
+ out += '}';
485
+ return out;
486
+ }
487
+ if (this._callable) {
488
+ throw new Error('Cannot dump callable to JSON');
489
+ }
490
+ if (this.isBoolean() && !toJson) {
491
+ return this._primitive.value ? 'True' : 'False';
492
+ }
493
+ if (this.isString()) {
494
+ return Value._dumpString(this._primitive.value, stringQuote);
495
+ }
496
+ if (this.isBoolean()) return this._primitive.value ? 'true' : 'false';
497
+ if (this.isNumberInteger()) return String(this._primitive.value);
498
+ if (this.isNumberFloat()) return String(this._primitive.value);
499
+ return 'null';
500
+ }
501
+
502
+ toJSON() {
503
+ if (this.isNull()) return null;
504
+ if (this.isPrimitive()) return this._primitive.value;
505
+ if (this._array) return this._array.map(v => v.toJSON());
506
+ if (this._object) {
507
+ const obj = {};
508
+ for (const [key, value] of this._object) {
509
+ obj[key] = value instanceof Value ? value.toJSON() : value;
510
+ }
511
+ return obj;
512
+ }
513
+ return null;
514
+ }
515
+ }
516
+
517
+ // ── ArgumentsValue ──────────────────────────────────────────────────────
518
+
519
+ class ArgumentsValue {
520
+ constructor() {
521
+ this.args = [];
522
+ this.kwargs = [];
523
+ }
524
+
525
+ hasNamed(name) {
526
+ return this.kwargs.some(([k]) => k === name);
527
+ }
528
+
529
+ getNamed(name) {
530
+ for (const [k, v] of this.kwargs) {
531
+ if (k === name) return v;
532
+ }
533
+ return new Value();
534
+ }
535
+
536
+ empty() {
537
+ return this.args.length === 0 && this.kwargs.length === 0;
538
+ }
539
+
540
+ expectArgs(methodName, posCount, kwCount) {
541
+ if (this.args.length < posCount[0] || this.args.length > posCount[1] ||
542
+ this.kwargs.length < kwCount[0] || this.kwargs.length > kwCount[1]) {
543
+ throw new Error(`${methodName} must have between ${posCount[0]} and ${posCount[1]} positional arguments and between ${kwCount[0]} and ${kwCount[1]} keyword arguments`);
544
+ }
545
+ }
546
+ }
547
+
548
+ // ── Context ─────────────────────────────────────────────────────────────
549
+
550
+ class Context {
551
+ constructor(values, parent = null) {
552
+ this._values = values;
553
+ this._parent = parent;
554
+ if (!values.isObject()) throw new Error('Context values must be an object: ' + values.dump());
555
+ }
556
+
557
+ static builtins() {
558
+ return _createBuiltins();
559
+ }
560
+
561
+ static make(bindings = {}, parent = null) {
562
+ let values;
563
+ if (bindings instanceof Value) {
564
+ values = bindings.isNull() ? Value.object() : bindings;
565
+ } else {
566
+ values = Value.fromJS(bindings);
567
+ }
568
+ return new Context(values, parent || Context.builtins());
569
+ }
570
+
571
+ _toKey(key) {
572
+ if (typeof key === 'string') return key;
573
+ if (key instanceof Value) return key.isString() ? key.value : String(key.value);
574
+ return String(key);
575
+ }
576
+
577
+ get(key) {
578
+ const k = this._toKey(key);
579
+ if (this._values.contains(k)) return this._values.get(k);
580
+ if (this._parent) return this._parent.get(k);
581
+ return new Value();
582
+ }
583
+
584
+ at(key) {
585
+ const k = this._toKey(key);
586
+ if (this._values.contains(k)) return this._values.get(k);
587
+ if (this._parent) return this._parent.at(k);
588
+ throw new Error('Undefined variable: ' + k);
589
+ }
590
+
591
+ contains(key) {
592
+ const k = this._toKey(key);
593
+ if (this._values.contains(k)) return true;
594
+ if (this._parent) return this._parent.contains(k);
595
+ return false;
596
+ }
597
+
598
+ set(key, value) {
599
+ const k = this._toKey(key);
600
+ this._values.set(k, value instanceof Value ? value : Value.fromJS(value));
601
+ }
602
+ }
603
+
604
+ // ── LoopControlException ────────────────────────────────────────────────
605
+
606
+ const LoopControlType = { Break: 'break', Continue: 'continue' };
607
+
608
+ class LoopControlException extends Error {
609
+ constructor(controlType, message) {
610
+ super(message || (controlType + ' outside of a loop'));
611
+ this.controlType = controlType;
612
+ }
613
+ }
614
+
615
+ // ── Error location ──────────────────────────────────────────────────────
616
+
617
+ function errorLocationSuffix(source, pos) {
618
+ const getLine = (lineNum) => {
619
+ let start = 0;
620
+ for (let i = 1; i < lineNum; i++) {
621
+ const idx = source.indexOf('\n', start);
622
+ start = idx === -1 ? source.length : idx + 1;
623
+ }
624
+ const end = source.indexOf('\n', start);
625
+ return source.substring(start, end === -1 ? source.length : end);
626
+ };
627
+ const before = source.substring(0, pos);
628
+ const line = (before.match(/\n/g) || []).length + 1;
629
+ const maxLine = (source.match(/\n/g) || []).length + 1;
630
+ const lastNl = before.lastIndexOf('\n');
631
+ const col = pos - lastNl;
632
+
633
+ let out = ` at row ${line}, column ${col}:\n`;
634
+ if (line > 1) out += getLine(line - 1) + '\n';
635
+ out += getLine(line) + '\n';
636
+ out += ' '.repeat(col - 1) + '^\n';
637
+ if (line < maxLine) out += getLine(line + 1) + '\n';
638
+ return out;
639
+ }
640
+
641
+ // ── Expression AST ──────────────────────────────────────────────────────
642
+
643
+ class Expression {
644
+ constructor(location) { this.location = location; }
645
+ evaluate(context) {
646
+ try {
647
+ return this.doEvaluate(context);
648
+ } catch (e) {
649
+ if (e._hasLocation) throw e;
650
+ if (this.location?.source) {
651
+ e.message += errorLocationSuffix(this.location.source, this.location.pos);
652
+ e._hasLocation = true;
653
+ }
654
+ throw e;
655
+ }
656
+ }
657
+ doEvaluate(_context) { throw new Error('Not implemented'); }
658
+ }
659
+
660
+ class LiteralExpr extends Expression {
661
+ constructor(loc, value) { super(loc); this.value = value; }
662
+ doEvaluate() { return this.value; }
663
+ }
664
+
665
+ class VariableExpr extends Expression {
666
+ constructor(loc, name) { super(loc); this.name = name; }
667
+ getName() { return this.name; }
668
+ doEvaluate(context) {
669
+ if (!context.contains(this.name)) return new Value();
670
+ return context.at(this.name);
671
+ }
672
+ }
673
+
674
+ class ArrayExpr extends Expression {
675
+ constructor(loc, elements) { super(loc); this.elements = elements; }
676
+ doEvaluate(context) {
677
+ const result = Value.array();
678
+ for (const e of this.elements) {
679
+ if (!e) throw new Error('Array element is null');
680
+ result.pushBack(e.evaluate(context));
681
+ }
682
+ return result;
683
+ }
684
+ }
685
+
686
+ class DictExpr extends Expression {
687
+ constructor(loc, elements) { super(loc); this.elements = elements; }
688
+ doEvaluate(context) {
689
+ const result = Value.object();
690
+ for (const [key, value] of this.elements) {
691
+ if (!key) throw new Error('Dict key is null');
692
+ if (!value) throw new Error('Dict value is null');
693
+ result.set(key.evaluate(context), value.evaluate(context));
694
+ }
695
+ return result;
696
+ }
697
+ }
698
+
699
+ class SliceExpr extends Expression {
700
+ constructor(loc, start, end, step) {
701
+ super(loc);
702
+ this.start = start;
703
+ this.end = end;
704
+ this.step = step;
705
+ }
706
+ doEvaluate() { throw new Error('SliceExpr not implemented'); }
707
+ }
708
+
709
+ class SubscriptExpr extends Expression {
710
+ constructor(loc, base, index) { super(loc); this.base = base; this.index = index; }
711
+ doEvaluate(context) {
712
+ if (!this.base) throw new Error('SubscriptExpr.base is null');
713
+ if (!this.index) throw new Error('SubscriptExpr.index is null');
714
+ const targetValue = this.base.evaluate(context);
715
+
716
+ if (this.index instanceof SliceExpr) {
717
+ const slice = this.index;
718
+ const len = targetValue.size;
719
+ const wrap = (i) => i < 0 ? i + len : i;
720
+
721
+ const stepVal = slice.step ? slice.step.evaluate(context).value : 1;
722
+ if (!stepVal) throw new Error('slice step cannot be zero');
723
+
724
+ const startVal = slice.start ? wrap(slice.start.evaluate(context).value) : (stepVal < 0 ? len - 1 : 0);
725
+ const endVal = slice.end ? wrap(slice.end.evaluate(context).value) : (stepVal < 0 ? -1 : len);
726
+
727
+ if (targetValue.isString()) {
728
+ const s = targetValue.value;
729
+ let result = '';
730
+ if (startVal < endVal && stepVal === 1) {
731
+ result = s.substring(startVal, endVal);
732
+ } else {
733
+ for (let i = startVal; stepVal > 0 ? i < endVal : i > endVal; i += stepVal) {
734
+ result += s[i];
735
+ }
736
+ }
737
+ return Value.fromJS(result);
738
+ }
739
+ if (targetValue.isArray()) {
740
+ const result = Value.array();
741
+ for (let i = startVal; stepVal > 0 ? i < endVal : i > endVal; i += stepVal) {
742
+ result.pushBack(targetValue.at(i));
743
+ }
744
+ return result;
745
+ }
746
+ throw new Error(targetValue.isNull() ? 'Cannot subscript null' : 'Subscripting only supported on arrays and strings');
747
+ }
748
+
749
+ const indexValue = this.index.evaluate(context);
750
+ if (targetValue.isNull()) {
751
+ if (this.base instanceof VariableExpr) {
752
+ throw new Error("'" + this.base.getName() + "' is " + (context.contains(this.base.getName()) ? 'null' : 'not defined'));
753
+ }
754
+ throw new Error("Trying to access property '" + indexValue.dump() + "' on null!");
755
+ }
756
+ return targetValue.get(indexValue);
757
+ }
758
+ }
759
+
760
+ class UnaryOpExpr extends Expression {
761
+ constructor(loc, expr, op) { super(loc); this.expr = expr; this.op = op; }
762
+ doEvaluate(context) {
763
+ if (!this.expr) throw new Error('UnaryOpExpr.expr is null');
764
+ const e = this.expr.evaluate(context);
765
+ switch (this.op) {
766
+ case 'Plus': return e;
767
+ case 'Minus': return e.neg();
768
+ case 'LogicalNot': return Value.fromJS(!e.toBool());
769
+ case 'Expansion':
770
+ case 'ExpansionDict':
771
+ throw new Error('Expansion operator is only supported in function calls and collections');
772
+ }
773
+ throw new Error('Unknown unary operator');
774
+ }
775
+ }
776
+
777
+ function valueIn(value, container) {
778
+ return ((container.isArray() || container.isObject()) && container.contains(value)) ||
779
+ (value.isString() && container.isString() && container.value.includes(value.value));
780
+ }
781
+
782
+ class BinaryOpExpr extends Expression {
783
+ constructor(loc, left, right, op) {
784
+ super(loc);
785
+ this.left = left;
786
+ this.right = right;
787
+ this.op = op;
788
+ }
789
+ doEvaluate(context) {
790
+ if (!this.left) throw new Error('BinaryOpExpr.left is null');
791
+ if (!this.right) throw new Error('BinaryOpExpr.right is null');
792
+ const l = this.left.evaluate(context);
793
+
794
+ const doEval = (lVal) => {
795
+ if (this.op === 'Is' || this.op === 'IsNot') {
796
+ if (!(this.right instanceof VariableExpr)) throw new Error("Right side of 'is' operator must be a variable");
797
+ const name = this.right.getName();
798
+ let result;
799
+ if (name === 'none') result = lVal.isNull();
800
+ else if (name === 'boolean') result = lVal.isBoolean();
801
+ else if (name === 'integer') result = lVal.isNumberInteger();
802
+ else if (name === 'float') result = lVal.isNumberFloat();
803
+ else if (name === 'number') result = lVal.isNumber();
804
+ else if (name === 'string') result = lVal.isString();
805
+ else if (name === 'mapping') result = lVal.isObject();
806
+ else if (name === 'iterable') result = lVal.isIterable();
807
+ else if (name === 'sequence') result = lVal.isArray();
808
+ else if (name === 'defined') result = !lVal.isNull();
809
+ else if (name === 'true') result = lVal.toBool();
810
+ else if (name === 'false') result = !lVal.toBool();
811
+ else throw new Error("Unknown type for 'is' operator: " + name);
812
+ return Value.fromJS(this.op === 'Is' ? result : !result);
813
+ }
814
+
815
+ if (this.op === 'And') {
816
+ if (!lVal.toBool()) return Value.fromJS(false);
817
+ return Value.fromJS(this.right.evaluate(context).toBool());
818
+ }
819
+ if (this.op === 'Or') {
820
+ if (lVal.toBool()) return lVal;
821
+ return this.right.evaluate(context);
822
+ }
823
+
824
+ const r = this.right.evaluate(context);
825
+ switch (this.op) {
826
+ case 'StrConcat': return Value.fromJS(lVal.toStr() + r.toStr());
827
+ case 'Add': return lVal.add(r);
828
+ case 'Sub': return lVal.sub(r);
829
+ case 'Mul': return lVal.mul(r);
830
+ case 'Div': return lVal.div(r);
831
+ case 'MulMul': return Value.fromJS(Math.pow(lVal.getNumber(), r.getNumber()));
832
+ case 'DivDiv': return Value.fromPrimitive({ type: 'int', value: Math.trunc(lVal.getInt() / r.getInt()) });
833
+ case 'Mod': return lVal.mod(r);
834
+ case 'Eq': return Value.fromJS(lVal.eq(r));
835
+ case 'Ne': return Value.fromJS(lVal.ne(r));
836
+ case 'Lt': return Value.fromJS(lVal.lt(r));
837
+ case 'Gt': return Value.fromJS(lVal.gt(r));
838
+ case 'Le': return Value.fromJS(lVal.le(r));
839
+ case 'Ge': return Value.fromJS(lVal.ge(r));
840
+ case 'In': return Value.fromJS(valueIn(lVal, r));
841
+ case 'NotIn': return Value.fromJS(!valueIn(lVal, r));
842
+ }
843
+ throw new Error('Unknown binary operator');
844
+ };
845
+
846
+ if (l.isCallable()) {
847
+ return Value.callable((ctx, args) => {
848
+ const ll = l.call(ctx, args);
849
+ return doEval(ll);
850
+ });
851
+ }
852
+ return doEval(l);
853
+ }
854
+ }
855
+
856
+ class ArgumentsExpression {
857
+ constructor() {
858
+ this.args = [];
859
+ this.kwargs = [];
860
+ }
861
+
862
+ evaluate(context) {
863
+ const vargs = new ArgumentsValue();
864
+ for (const arg of this.args) {
865
+ if (arg instanceof UnaryOpExpr) {
866
+ if (arg.op === 'Expansion') {
867
+ const array = arg.expr.evaluate(context);
868
+ if (!array.isArray()) throw new Error('Expansion operator only supported on arrays');
869
+ array.forEach((v) => vargs.args.push(v));
870
+ continue;
871
+ } else if (arg.op === 'ExpansionDict') {
872
+ const dict = arg.expr.evaluate(context);
873
+ if (!dict.isObject()) throw new Error('ExpansionDict operator only supported on objects');
874
+ dict.forEach((key) => {
875
+ vargs.kwargs.push([key.value, dict.at(key)]);
876
+ });
877
+ continue;
878
+ }
879
+ }
880
+ vargs.args.push(arg.evaluate(context));
881
+ }
882
+ for (const [name, value] of this.kwargs) {
883
+ vargs.kwargs.push([name, value.evaluate(context)]);
884
+ }
885
+ return vargs;
886
+ }
887
+ }
888
+
889
+ // ── String helpers ──────────────────────────────────────────────────────
890
+
891
+ function strip(s, chars = '', left = true, right = true) {
892
+ const charset = chars || ' \t\n\r';
893
+ let start = 0;
894
+ let end = s.length - 1;
895
+ if (left) {
896
+ while (start <= end && charset.includes(s[start])) start++;
897
+ }
898
+ if (right) {
899
+ while (end >= start && charset.includes(s[end])) end--;
900
+ }
901
+ return s.substring(start, end + 1);
902
+ }
903
+
904
+ function splitStr(s, sep) {
905
+ const result = [];
906
+ let start = 0;
907
+ let idx;
908
+ while ((idx = s.indexOf(sep, start)) !== -1) {
909
+ result.push(s.substring(start, idx));
910
+ start = idx + sep.length;
911
+ }
912
+ result.push(s.substring(start));
913
+ return result;
914
+ }
915
+
916
+ function capitalize(s) {
917
+ if (!s) return s;
918
+ return s[0].toUpperCase() + s.slice(1).toLowerCase();
919
+ }
920
+
921
+ function htmlEscape(s) {
922
+ let result = '';
923
+ for (const c of s) {
924
+ switch (c) {
925
+ case '&': result += '&amp;'; break;
926
+ case '<': result += '&lt;'; break;
927
+ case '>': result += '&gt;'; break;
928
+ case '"': result += '&#34;'; break;
929
+ case "'": result += '&apos;'; break;
930
+ default: result += c;
931
+ }
932
+ }
933
+ return result;
934
+ }
935
+
936
+ // ── Method Call Expression ───────────────────────────────────────────────
937
+
938
+ class MethodCallExpr extends Expression {
939
+ constructor(loc, object, method, args) {
940
+ super(loc);
941
+ this.object = object;
942
+ this.method = method;
943
+ this.args = args;
944
+ }
945
+ doEvaluate(context) {
946
+ if (!this.object) throw new Error('MethodCallExpr.object is null');
947
+ if (!this.method) throw new Error('MethodCallExpr.method is null');
948
+ const obj = this.object.evaluate(context);
949
+ const vargs = this.args.evaluate(context);
950
+ const methodName = this.method.getName();
951
+
952
+ if (obj.isNull()) {
953
+ throw new Error("Trying to call method '" + methodName + "' on null");
954
+ }
955
+
956
+ if (obj.isArray()) {
957
+ if (methodName === 'append') {
958
+ vargs.expectArgs('append method', [1, 1], [0, 0]);
959
+ obj.pushBack(vargs.args[0]);
960
+ return new Value();
961
+ }
962
+ if (methodName === 'pop') {
963
+ vargs.expectArgs('pop method', [0, 1], [0, 0]);
964
+ return obj.pop(vargs.args.length === 0 ? undefined : vargs.args[0]);
965
+ }
966
+ if (methodName === 'insert') {
967
+ vargs.expectArgs('insert method', [2, 2], [0, 0]);
968
+ const index = vargs.args[0].value;
969
+ if (index < 0 || index > obj.size) throw new Error('Index out of range for insert method');
970
+ obj.insert(index, vargs.args[1]);
971
+ return new Value();
972
+ }
973
+ } else if (obj.isObject()) {
974
+ if (methodName === 'items') {
975
+ vargs.expectArgs('items method', [0, 0], [0, 0]);
976
+ const result = Value.array();
977
+ for (const key of obj.keys()) {
978
+ result.pushBack(Value.array([key, obj.at(key)]));
979
+ }
980
+ return result;
981
+ }
982
+ if (methodName === 'pop') {
983
+ vargs.expectArgs('pop method', [1, 1], [0, 0]);
984
+ return obj.pop(vargs.args[0]);
985
+ }
986
+ if (methodName === 'keys') {
987
+ vargs.expectArgs('keys method', [0, 0], [0, 0]);
988
+ const result = Value.array();
989
+ for (const key of obj.keys()) {
990
+ result.pushBack(key);
991
+ }
992
+ return result;
993
+ }
994
+ if (methodName === 'get') {
995
+ vargs.expectArgs('get method', [1, 2], [0, 0]);
996
+ const key = vargs.args[0];
997
+ if (vargs.args.length === 1) {
998
+ return obj.contains(key) ? obj.at(key) : new Value();
999
+ }
1000
+ return obj.contains(key) ? obj.at(key) : vargs.args[1];
1001
+ }
1002
+ if (obj.contains(methodName)) {
1003
+ const callable = obj.get(methodName);
1004
+ if (!callable.isCallable()) throw new Error("Property '" + methodName + "' is not callable");
1005
+ return callable.call(context, vargs);
1006
+ }
1007
+ } else if (obj.isString()) {
1008
+ const str = obj.value;
1009
+ if (methodName === 'strip') {
1010
+ vargs.expectArgs('strip method', [0, 1], [0, 0]);
1011
+ const chars = vargs.args.length === 0 ? '' : vargs.args[0].value;
1012
+ return Value.fromJS(strip(str, chars));
1013
+ }
1014
+ if (methodName === 'lstrip') {
1015
+ vargs.expectArgs('lstrip method', [0, 1], [0, 0]);
1016
+ const chars = vargs.args.length === 0 ? '' : vargs.args[0].value;
1017
+ return Value.fromJS(strip(str, chars, true, false));
1018
+ }
1019
+ if (methodName === 'rstrip') {
1020
+ vargs.expectArgs('rstrip method', [0, 1], [0, 0]);
1021
+ const chars = vargs.args.length === 0 ? '' : vargs.args[0].value;
1022
+ return Value.fromJS(strip(str, chars, false, true));
1023
+ }
1024
+ if (methodName === 'split') {
1025
+ vargs.expectArgs('split method', [1, 1], [0, 0]);
1026
+ const sep = vargs.args[0].value;
1027
+ const parts = splitStr(str, sep);
1028
+ const result = Value.array();
1029
+ for (const p of parts) result.pushBack(Value.fromJS(p));
1030
+ return result;
1031
+ }
1032
+ if (methodName === 'capitalize') {
1033
+ vargs.expectArgs('capitalize method', [0, 0], [0, 0]);
1034
+ return Value.fromJS(capitalize(str));
1035
+ }
1036
+ if (methodName === 'upper') {
1037
+ vargs.expectArgs('upper method', [0, 0], [0, 0]);
1038
+ return Value.fromJS(str.toUpperCase());
1039
+ }
1040
+ if (methodName === 'lower') {
1041
+ vargs.expectArgs('lower method', [0, 0], [0, 0]);
1042
+ return Value.fromJS(str.toLowerCase());
1043
+ }
1044
+ if (methodName === 'endswith') {
1045
+ vargs.expectArgs('endswith method', [1, 1], [0, 0]);
1046
+ return Value.fromJS(str.endsWith(vargs.args[0].value));
1047
+ }
1048
+ if (methodName === 'startswith') {
1049
+ vargs.expectArgs('startswith method', [1, 1], [0, 0]);
1050
+ return Value.fromJS(str.startsWith(vargs.args[0].value));
1051
+ }
1052
+ if (methodName === 'title') {
1053
+ vargs.expectArgs('title method', [0, 0], [0, 0]);
1054
+ let res = '';
1055
+ for (let i = 0; i < str.length; i++) {
1056
+ if (i === 0 || /\s/.test(str[i - 1])) res += str[i].toUpperCase();
1057
+ else res += str[i].toLowerCase();
1058
+ }
1059
+ return Value.fromJS(res);
1060
+ }
1061
+ if (methodName === 'replace') {
1062
+ vargs.expectArgs('replace method', [2, 3], [0, 0]);
1063
+ const before = vargs.args[0].value;
1064
+ const after = vargs.args[1].value;
1065
+ let count = vargs.args.length === 3 ? vargs.args[2].value : str.length;
1066
+ let result = str;
1067
+ let startPos = 0;
1068
+ while (count > 0) {
1069
+ const idx = result.indexOf(before, startPos);
1070
+ if (idx === -1) break;
1071
+ result = result.substring(0, idx) + after + result.substring(idx + before.length);
1072
+ startPos = idx + after.length;
1073
+ count--;
1074
+ }
1075
+ return Value.fromJS(result);
1076
+ }
1077
+ }
1078
+ throw new Error('Unknown method: ' + methodName);
1079
+ }
1080
+ }
1081
+
1082
+ class CallExpr extends Expression {
1083
+ constructor(loc, object, args) {
1084
+ super(loc);
1085
+ this.object = object;
1086
+ this.args = args;
1087
+ }
1088
+ doEvaluate(context) {
1089
+ if (!this.object) throw new Error('CallExpr.object is null');
1090
+ const obj = this.object.evaluate(context);
1091
+ if (!obj.isCallable()) throw new Error('Object is not callable: ' + obj.dump(2));
1092
+ const vargs = this.args.evaluate(context);
1093
+ return obj.call(context, vargs);
1094
+ }
1095
+ }
1096
+
1097
+ class FilterExpr extends Expression {
1098
+ constructor(loc, parts) { super(loc); this.parts = parts; }
1099
+ doEvaluate(context) {
1100
+ let result;
1101
+ let first = true;
1102
+ for (const part of this.parts) {
1103
+ if (!part) throw new Error('FilterExpr.part is null');
1104
+ if (first) {
1105
+ first = false;
1106
+ result = part.evaluate(context);
1107
+ } else {
1108
+ if (part instanceof CallExpr) {
1109
+ const target = part.object.evaluate(context);
1110
+ const args = part.args.evaluate(context);
1111
+ args.args.unshift(result);
1112
+ result = target.call(context, args);
1113
+ } else {
1114
+ const callable = part.evaluate(context);
1115
+ const args = new ArgumentsValue();
1116
+ args.args.push(result);
1117
+ result = callable.call(context, args);
1118
+ }
1119
+ }
1120
+ }
1121
+ return result;
1122
+ }
1123
+ prepend(expr) { this.parts.unshift(expr); }
1124
+ }
1125
+
1126
+ class IfExpr extends Expression {
1127
+ constructor(loc, condition, thenExpr, elseExpr) {
1128
+ super(loc);
1129
+ this.condition = condition;
1130
+ this.thenExpr = thenExpr;
1131
+ this.elseExpr = elseExpr;
1132
+ }
1133
+ doEvaluate(context) {
1134
+ if (!this.condition) throw new Error('IfExpr.condition is null');
1135
+ if (!this.thenExpr) throw new Error('IfExpr.then_expr is null');
1136
+ if (this.condition.evaluate(context).toBool()) return this.thenExpr.evaluate(context);
1137
+ if (this.elseExpr) return this.elseExpr.evaluate(context);
1138
+ return new Value();
1139
+ }
1140
+ }
1141
+
1142
+ // ── TemplateNode AST ────────────────────────────────────────────────────
1143
+
1144
+ class TemplateNode {
1145
+ constructor(location) { this._location = location; }
1146
+
1147
+ render(contextOrWrite, context) {
1148
+ if (typeof contextOrWrite === 'function') {
1149
+ this._render(contextOrWrite, context);
1150
+ return;
1151
+ }
1152
+ let out = '';
1153
+ this._render((s) => { out += s; }, contextOrWrite);
1154
+ return out;
1155
+ }
1156
+
1157
+ _render(write, context) {
1158
+ try {
1159
+ this.doRender(write, context);
1160
+ } catch (e) {
1161
+ if (e instanceof LoopControlException) {
1162
+ if (e._hasLocation) throw e;
1163
+ if (this._location?.source) {
1164
+ const newMsg = e.message + errorLocationSuffix(this._location.source, this._location.pos);
1165
+ const newErr = new LoopControlException(e.controlType, newMsg);
1166
+ newErr._hasLocation = true;
1167
+ throw newErr;
1168
+ }
1169
+ throw e;
1170
+ }
1171
+ if (e._hasLocation) throw e;
1172
+ if (this._location?.source) {
1173
+ e.message += errorLocationSuffix(this._location.source, this._location.pos);
1174
+ e._hasLocation = true;
1175
+ }
1176
+ throw e;
1177
+ }
1178
+ }
1179
+
1180
+ doRender(_write, _context) { throw new Error('Not implemented'); }
1181
+ }
1182
+
1183
+ class SequenceNode extends TemplateNode {
1184
+ constructor(loc, children) { super(loc); this.children = children; }
1185
+ doRender(write, context) {
1186
+ for (const child of this.children) child._render(write, context);
1187
+ }
1188
+ }
1189
+
1190
+ class TextNode extends TemplateNode {
1191
+ constructor(loc, text) { super(loc); this.text = text; }
1192
+ doRender(write) { write(this.text); }
1193
+ }
1194
+
1195
+ class ExpressionNode extends TemplateNode {
1196
+ constructor(loc, expr) { super(loc); this.expr = expr; }
1197
+ doRender(write, context) {
1198
+ if (!this.expr) throw new Error('ExpressionNode.expr is null');
1199
+ const result = this.expr.evaluate(context);
1200
+ if (result.isString()) write(result.value);
1201
+ else if (result.isBoolean()) write(result.value ? 'True' : 'False');
1202
+ else if (!result.isNull()) write(result.dump());
1203
+ }
1204
+ }
1205
+
1206
+ class IfNode extends TemplateNode {
1207
+ constructor(loc, cascade) { super(loc); this.cascade = cascade; }
1208
+ doRender(write, context) {
1209
+ for (const [condition, body] of this.cascade) {
1210
+ let enter = true;
1211
+ if (condition) enter = condition.evaluate(context).toBool();
1212
+ if (enter) {
1213
+ if (!body) throw new Error('IfNode.cascade.second is null');
1214
+ body._render(write, context);
1215
+ return;
1216
+ }
1217
+ }
1218
+ }
1219
+ }
1220
+
1221
+ class LoopControlNode extends TemplateNode {
1222
+ constructor(loc, controlType) { super(loc); this.controlType = controlType; }
1223
+ doRender() { throw new LoopControlException(this.controlType); }
1224
+ }
1225
+
1226
+ function destructuringAssign(varNames, context, item) {
1227
+ if (varNames.length === 1) {
1228
+ context.set(varNames[0], item);
1229
+ } else {
1230
+ if (!item.isArray() || item.size !== varNames.length) {
1231
+ throw new Error('Mismatched number of variables and items in destructuring assignment');
1232
+ }
1233
+ for (let i = 0; i < varNames.length; i++) {
1234
+ context.set(varNames[i], item.at(i));
1235
+ }
1236
+ }
1237
+ }
1238
+
1239
+ class ForNode extends TemplateNode {
1240
+ constructor(loc, varNames, iterable, condition, body, recursive, elseBody) {
1241
+ super(loc);
1242
+ this.varNames = varNames;
1243
+ this.iterable = iterable;
1244
+ this.condition = condition;
1245
+ this.body = body;
1246
+ this.recursive = recursive;
1247
+ this.elseBody = elseBody;
1248
+ }
1249
+ doRender(write, context) {
1250
+ if (!this.iterable) throw new Error('ForNode.iterable is null');
1251
+ if (!this.body) throw new Error('ForNode.body is null');
1252
+
1253
+ const iterableValue = this.iterable.evaluate(context);
1254
+ let loopFunction;
1255
+
1256
+ const visit = (iter) => {
1257
+ const filteredItems = Value.array();
1258
+ if (!iter.isNull()) {
1259
+ if (!iterableValue.isIterable()) throw new Error('For loop iterable must be iterable: ' + iterableValue.dump());
1260
+ iterableValue.forEach((item) => {
1261
+ destructuringAssign(this.varNames, context, item);
1262
+ if (!this.condition || this.condition.evaluate(context).toBool()) {
1263
+ filteredItems.pushBack(item);
1264
+ }
1265
+ });
1266
+ }
1267
+
1268
+ if (filteredItems.size === 0) {
1269
+ if (this.elseBody) this.elseBody._render(write, context);
1270
+ } else {
1271
+ const loop = this.recursive ? Value.callable(loopFunction) : Value.object();
1272
+ loop.set('length', Value.fromJS(filteredItems.size));
1273
+
1274
+ let cycleIndex = 0;
1275
+ loop.set('cycle', Value.callable((_ctx, args) => {
1276
+ if (args.args.length === 0 || args.kwargs.length > 0) {
1277
+ throw new Error('cycle() expects at least 1 positional argument and no named arg');
1278
+ }
1279
+ const item = args.args[cycleIndex];
1280
+ cycleIndex = (cycleIndex + 1) % args.args.length;
1281
+ return item;
1282
+ }));
1283
+
1284
+ const loopContext = new Context(Value.object(), context);
1285
+ loopContext.set('loop', loop);
1286
+ const n = filteredItems.size;
1287
+ for (let i = 0; i < n; i++) {
1288
+ const item = filteredItems.at(i);
1289
+ destructuringAssign(this.varNames, loopContext, item);
1290
+ loop.set('index', Value.fromJS(i + 1));
1291
+ loop.set('index0', Value.fromJS(i));
1292
+ loop.set('revindex', Value.fromJS(n - i));
1293
+ loop.set('revindex0', Value.fromJS(n - i - 1));
1294
+ loop.set('length', Value.fromJS(n));
1295
+ loop.set('first', Value.fromJS(i === 0));
1296
+ loop.set('last', Value.fromJS(i === n - 1));
1297
+ loop.set('previtem', i > 0 ? filteredItems.at(i - 1) : new Value());
1298
+ loop.set('nextitem', i < n - 1 ? filteredItems.at(i + 1) : new Value());
1299
+ try {
1300
+ this.body._render(write, loopContext);
1301
+ } catch (e) {
1302
+ if (e instanceof LoopControlException) {
1303
+ if (e.controlType === LoopControlType.Break) break;
1304
+ if (e.controlType === LoopControlType.Continue) continue;
1305
+ }
1306
+ throw e;
1307
+ }
1308
+ }
1309
+ }
1310
+ };
1311
+
1312
+ if (this.recursive) {
1313
+ loopFunction = (_ctx, args) => {
1314
+ if (args.args.length !== 1 || args.kwargs.length > 0 || !args.args[0].isArray()) {
1315
+ throw new Error('loop() expects exactly 1 positional iterable argument');
1316
+ }
1317
+ visit(args.args[0]);
1318
+ return new Value();
1319
+ };
1320
+ }
1321
+
1322
+ visit(iterableValue);
1323
+ }
1324
+ }
1325
+
1326
+ class MacroNode extends TemplateNode {
1327
+ constructor(loc, name, params, body) {
1328
+ super(loc);
1329
+ this.name = name;
1330
+ this.params = params;
1331
+ this.body = body;
1332
+ this.namedParamPositions = new Map();
1333
+ for (let i = 0; i < params.length; i++) {
1334
+ if (params[i][0]) this.namedParamPositions.set(params[i][0], i);
1335
+ }
1336
+ }
1337
+ doRender(_write, macroContext) {
1338
+ if (!this.name) throw new Error('MacroNode.name is null');
1339
+ if (!this.body) throw new Error('MacroNode.body is null');
1340
+ const self = this;
1341
+ const callable = Value.callable((callContext, args) => {
1342
+ const executionContext = new Context(Value.object(), macroContext);
1343
+
1344
+ if (callContext.contains('caller')) {
1345
+ executionContext.set('caller', callContext.get('caller'));
1346
+ }
1347
+
1348
+ const paramSet = new Array(self.params.length).fill(false);
1349
+ for (let i = 0; i < args.args.length; i++) {
1350
+ if (i >= self.params.length) throw new Error('Too many positional arguments for macro ' + self.name.getName());
1351
+ paramSet[i] = true;
1352
+ executionContext.set(self.params[i][0], args.args[i]);
1353
+ }
1354
+ for (const [argName, value] of args.kwargs) {
1355
+ const pos = self.namedParamPositions.get(argName);
1356
+ if (pos === undefined) throw new Error('Unknown parameter name for macro ' + self.name.getName() + ': ' + argName);
1357
+ executionContext.set(argName, value);
1358
+ paramSet[pos] = true;
1359
+ }
1360
+ for (let i = 0; i < self.params.length; i++) {
1361
+ if (!paramSet[i] && self.params[i][1] !== null) {
1362
+ const val = self.params[i][1].evaluate(callContext);
1363
+ executionContext.set(self.params[i][0], val);
1364
+ }
1365
+ }
1366
+ return Value.fromJS(self.body.render(executionContext));
1367
+ });
1368
+ macroContext.set(this.name.getName(), callable);
1369
+ }
1370
+ }
1371
+
1372
+ class FilterNode extends TemplateNode {
1373
+ constructor(loc, filter, body) { super(loc); this.filter = filter; this.body = body; }
1374
+ doRender(write, context) {
1375
+ if (!this.filter) throw new Error('FilterNode.filter is null');
1376
+ if (!this.body) throw new Error('FilterNode.body is null');
1377
+ const filterValue = this.filter.evaluate(context);
1378
+ if (!filterValue.isCallable()) throw new Error('Filter must be a callable: ' + filterValue.dump());
1379
+ const renderedBody = this.body.render(context);
1380
+ const filterArgs = new ArgumentsValue();
1381
+ filterArgs.args.push(Value.fromJS(renderedBody));
1382
+ const result = filterValue.call(context, filterArgs);
1383
+ write(result.toStr());
1384
+ }
1385
+ }
1386
+
1387
+ class SetNode extends TemplateNode {
1388
+ constructor(loc, ns, varNames, value) {
1389
+ super(loc);
1390
+ this.ns = ns;
1391
+ this.varNames = varNames;
1392
+ this.value = value;
1393
+ }
1394
+ doRender(_write, context) {
1395
+ if (!this.value) throw new Error('SetNode.value is null');
1396
+ if (this.ns) {
1397
+ if (this.varNames.length !== 1) throw new Error('Namespaced set only supports a single variable name');
1398
+ const nsValue = context.get(this.ns);
1399
+ if (!nsValue.isObject()) throw new Error("Namespace '" + this.ns + "' is not an object");
1400
+ nsValue.set(this.varNames[0], this.value.evaluate(context));
1401
+ } else {
1402
+ const val = this.value.evaluate(context);
1403
+ destructuringAssign(this.varNames, context, val);
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ class SetTemplateNode extends TemplateNode {
1409
+ constructor(loc, name, templateValue) { super(loc); this.name = name; this.templateValue = templateValue; }
1410
+ doRender(_write, context) {
1411
+ if (!this.templateValue) throw new Error('SetTemplateNode.template_value is null');
1412
+ context.set(this.name, Value.fromJS(this.templateValue.render(context)));
1413
+ }
1414
+ }
1415
+
1416
+ class CallNode extends TemplateNode {
1417
+ constructor(loc, expr, body) { super(loc); this.expr = expr; this.body = body; }
1418
+ doRender(write, context) {
1419
+ if (!this.expr) throw new Error('CallNode.expr is null');
1420
+ if (!this.body) throw new Error('CallNode.body is null');
1421
+
1422
+ const caller = Value.callable(() => Value.fromJS(this.body.render(context)));
1423
+ context.set('caller', caller);
1424
+
1425
+ if (!(this.expr instanceof CallExpr)) {
1426
+ throw new Error('Invalid call block syntax - expected function call');
1427
+ }
1428
+
1429
+ const fn = this.expr.object.evaluate(context);
1430
+ if (!fn.isCallable()) throw new Error('Call target must be callable: ' + fn.dump());
1431
+ const args = this.expr.args.evaluate(context);
1432
+ const result = fn.call(context, args);
1433
+ write(result.toStr());
1434
+ }
1435
+ }
1436
+
1437
+ // ── Parser ──────────────────────────────────────────────────────────────
1438
+
1439
+ class Parser {
1440
+ constructor(templateStr, options) {
1441
+ this.source = templateStr;
1442
+ this.pos = 0;
1443
+ this.options = options;
1444
+ }
1445
+
1446
+ getLocation() { return { source: this.source, pos: this.pos }; }
1447
+
1448
+ consumeSpaces(mode = 'Strip') {
1449
+ if (mode === 'Strip') {
1450
+ while (this.pos < this.source.length && /\s/.test(this.source[this.pos])) this.pos++;
1451
+ }
1452
+ return true;
1453
+ }
1454
+
1455
+ peekSymbols(symbols) {
1456
+ for (const sym of symbols) {
1457
+ if (this.pos + sym.length <= this.source.length && this.source.substring(this.pos, this.pos + sym.length) === sym) return true;
1458
+ }
1459
+ return false;
1460
+ }
1461
+
1462
+ consumeToken(tokenOrRegex, spaceHandling = 'Strip') {
1463
+ const start = this.pos;
1464
+ this.consumeSpaces(spaceHandling);
1465
+ if (typeof tokenOrRegex === 'string') {
1466
+ if (this.pos + tokenOrRegex.length <= this.source.length && this.source.substring(this.pos, this.pos + tokenOrRegex.length) === tokenOrRegex) {
1467
+ this.pos += tokenOrRegex.length;
1468
+ return tokenOrRegex;
1469
+ }
1470
+ this.pos = start;
1471
+ return '';
1472
+ }
1473
+ const remaining = this.source.substring(this.pos);
1474
+ const match = remaining.match(tokenOrRegex);
1475
+ if (match && match.index === 0) {
1476
+ this.pos += match[0].length;
1477
+ return match[0];
1478
+ }
1479
+ this.pos = start;
1480
+ return '';
1481
+ }
1482
+
1483
+ consumeTokenGroups(regex, spaceHandling = 'Strip') {
1484
+ const start = this.pos;
1485
+ this.consumeSpaces(spaceHandling);
1486
+ const remaining = this.source.substring(this.pos);
1487
+ const match = remaining.match(regex);
1488
+ if (match && match.index === 0) {
1489
+ this.pos += match[0].length;
1490
+ return Array.from(match).map(m => m || '');
1491
+ }
1492
+ this.pos = start;
1493
+ return [];
1494
+ }
1495
+
1496
+ parseString() {
1497
+ const doParse = (quote) => {
1498
+ if (this.pos >= this.source.length || this.source[this.pos] !== quote) return null;
1499
+ let result = '';
1500
+ let escape = false;
1501
+ this.pos++;
1502
+ while (this.pos < this.source.length) {
1503
+ const c = this.source[this.pos];
1504
+ if (escape) {
1505
+ escape = false;
1506
+ switch (c) {
1507
+ case 'n': result += '\n'; break;
1508
+ case 'r': result += '\r'; break;
1509
+ case 't': result += '\t'; break;
1510
+ case 'b': result += '\b'; break;
1511
+ case 'f': result += '\f'; break;
1512
+ case '\\': result += '\\'; break;
1513
+ default: result += (c === quote) ? quote : c; break;
1514
+ }
1515
+ this.pos++;
1516
+ } else if (c === '\\') {
1517
+ escape = true;
1518
+ this.pos++;
1519
+ } else if (c === quote) {
1520
+ this.pos++;
1521
+ return result;
1522
+ } else {
1523
+ result += c;
1524
+ this.pos++;
1525
+ }
1526
+ }
1527
+ return null;
1528
+ };
1529
+ this.consumeSpaces();
1530
+ if (this.pos >= this.source.length) return null;
1531
+ if (this.source[this.pos] === '"') return doParse('"');
1532
+ if (this.source[this.pos] === "'") return doParse("'");
1533
+ return null;
1534
+ }
1535
+
1536
+ parseNumber() {
1537
+ const before = this.pos;
1538
+ this.consumeSpaces();
1539
+ const start = this.pos;
1540
+ let hasDecimal = false, hasExponent = false;
1541
+
1542
+ if (this.pos < this.source.length && (this.source[this.pos] === '-' || this.source[this.pos] === '+')) this.pos++;
1543
+
1544
+ while (this.pos < this.source.length) {
1545
+ const c = this.source[this.pos];
1546
+ if (/\d/.test(c)) { this.pos++; }
1547
+ else if (c === '.') {
1548
+ if (hasDecimal) throw new Error('Multiple decimal points');
1549
+ hasDecimal = true;
1550
+ this.pos++;
1551
+ } else if (this.pos !== start && (c === 'e' || c === 'E')) {
1552
+ if (hasExponent) throw new Error('Multiple exponents');
1553
+ hasExponent = true;
1554
+ this.pos++;
1555
+ } else { break; }
1556
+ }
1557
+
1558
+ if (start === this.pos) { this.pos = before; return null; }
1559
+ const str = this.source.substring(start, this.pos);
1560
+ const num = Number(str);
1561
+ if (isNaN(num)) throw new Error("Failed to parse number: '" + str + "'");
1562
+ return (hasDecimal || hasExponent) ? { type: 'float', value: num } : { type: 'int', value: num };
1563
+ }
1564
+
1565
+ parseConstant() {
1566
+ const start = this.pos;
1567
+ this.consumeSpaces();
1568
+ if (this.pos >= this.source.length) return null;
1569
+
1570
+ if (this.source[this.pos] === '"' || this.source[this.pos] === "'") {
1571
+ const str = this.parseString();
1572
+ if (str !== null) return Value.fromJS(str);
1573
+ }
1574
+
1575
+ const primTok = this.consumeToken(/^(?:true|True|false|False|None)\b/);
1576
+ if (primTok) {
1577
+ if (primTok === 'true' || primTok === 'True') return Value.fromJS(true);
1578
+ if (primTok === 'false' || primTok === 'False') return Value.fromJS(false);
1579
+ if (primTok === 'None') return new Value();
1580
+ }
1581
+
1582
+ const number = this.parseNumber();
1583
+ if (number !== null) return Value.fromPrimitive(number);
1584
+
1585
+ this.pos = start;
1586
+ return null;
1587
+ }
1588
+
1589
+ parseIdentifier() {
1590
+ const loc = this.getLocation();
1591
+ const ident = this.consumeToken(/^(?!(?:not|is|and|or|del)\b)[a-zA-Z_]\w*/);
1592
+ if (!ident) return null;
1593
+ return new VariableExpr(loc, ident);
1594
+ }
1595
+
1596
+ parseExpression(allowIfExpr = true) {
1597
+ const left = this.parseLogicalOr();
1598
+ if (this.pos >= this.source.length) return left;
1599
+ if (!allowIfExpr) return left;
1600
+ if (!this.consumeToken(/^if\b/)) return left;
1601
+ const loc = this.getLocation();
1602
+ const [condition, elseExpr] = this.parseIfExpression();
1603
+ return new IfExpr(loc, condition, left, elseExpr);
1604
+ }
1605
+
1606
+ parseIfExpression() {
1607
+ const condition = this.parseLogicalOr();
1608
+ if (!condition) throw new Error('Expected condition expression');
1609
+ let elseExpr = null;
1610
+ if (this.consumeToken(/^else\b/)) {
1611
+ elseExpr = this.parseExpression();
1612
+ if (!elseExpr) throw new Error("Expected 'else' expression");
1613
+ }
1614
+ return [condition, elseExpr];
1615
+ }
1616
+
1617
+ parseLogicalOr() {
1618
+ let left = this.parseLogicalAnd();
1619
+ if (!left) throw new Error("Expected left side of 'logical or' expression");
1620
+ while (this.consumeToken(/^or\b/)) {
1621
+ const right = this.parseLogicalAnd();
1622
+ if (!right) throw new Error("Expected right side of 'or' expression");
1623
+ left = new BinaryOpExpr(this.getLocation(), left, right, 'Or');
1624
+ }
1625
+ return left;
1626
+ }
1627
+
1628
+ parseLogicalAnd() {
1629
+ let left = this.parseLogicalNot();
1630
+ if (!left) throw new Error("Expected left side of 'logical and' expression");
1631
+ while (this.consumeToken(/^and\b/)) {
1632
+ const right = this.parseLogicalNot();
1633
+ if (!right) throw new Error("Expected right side of 'and' expression");
1634
+ left = new BinaryOpExpr(this.getLocation(), left, right, 'And');
1635
+ }
1636
+ return left;
1637
+ }
1638
+
1639
+ parseLogicalNot() {
1640
+ const loc = this.getLocation();
1641
+ if (this.consumeToken(/^not\b/)) {
1642
+ const sub = this.parseLogicalNot();
1643
+ if (!sub) throw new Error("Expected expression after 'not' keyword");
1644
+ return new UnaryOpExpr(loc, sub, 'LogicalNot');
1645
+ }
1646
+ return this.parseLogicalCompare();
1647
+ }
1648
+
1649
+ parseLogicalCompare() {
1650
+ let left = this.parseStringConcat();
1651
+ if (!left) throw new Error("Expected left side of 'logical compare' expression");
1652
+ let opStr;
1653
+ while ((opStr = this.consumeToken(/^(?:==|!=|<=?|>=?|in\b|is\b|not\s+in\b)/))) {
1654
+ const loc = this.getLocation();
1655
+ if (opStr === 'is') {
1656
+ const negated = !!this.consumeToken(/^not\b/);
1657
+ const identifier = this.parseIdentifier();
1658
+ if (!identifier) throw new Error("Expected identifier after 'is' keyword");
1659
+ return new BinaryOpExpr(left.location, left, identifier, negated ? 'IsNot' : 'Is');
1660
+ }
1661
+ const right = this.parseStringConcat();
1662
+ if (!right) throw new Error("Expected right side of 'logical compare' expression");
1663
+ let op;
1664
+ if (opStr === '==') op = 'Eq';
1665
+ else if (opStr === '!=') op = 'Ne';
1666
+ else if (opStr === '<') op = 'Lt';
1667
+ else if (opStr === '>') op = 'Gt';
1668
+ else if (opStr === '<=') op = 'Le';
1669
+ else if (opStr === '>=') op = 'Ge';
1670
+ else if (opStr === 'in') op = 'In';
1671
+ else if (opStr.startsWith('not')) op = 'NotIn';
1672
+ else throw new Error('Unknown comparison operator: ' + opStr);
1673
+ left = new BinaryOpExpr(loc, left, right, op);
1674
+ }
1675
+ return left;
1676
+ }
1677
+
1678
+ parseStringConcat() {
1679
+ let left = this.parseMathPow();
1680
+ if (!left) throw new Error("Expected left side of 'string concat' expression");
1681
+ if (this.consumeToken(/^~(?!\})/)) {
1682
+ const right = this.parseLogicalAnd();
1683
+ if (!right) throw new Error("Expected right side of 'string concat' expression");
1684
+ left = new BinaryOpExpr(this.getLocation(), left, right, 'StrConcat');
1685
+ }
1686
+ return left;
1687
+ }
1688
+
1689
+ parseMathPow() {
1690
+ let left = this.parseMathPlusMinus();
1691
+ if (!left) throw new Error("Expected left side of 'math pow' expression");
1692
+ while (this.consumeToken('**')) {
1693
+ const right = this.parseMathPlusMinus();
1694
+ if (!right) throw new Error("Expected right side of 'math pow' expression");
1695
+ left = new BinaryOpExpr(this.getLocation(), left, right, 'MulMul');
1696
+ }
1697
+ return left;
1698
+ }
1699
+
1700
+ parseMathPlusMinus() {
1701
+ let left = this.parseMathMulDiv();
1702
+ if (!left) throw new Error("Expected left side of 'math plus/minus' expression");
1703
+ let opStr;
1704
+ while ((opStr = this.consumeToken(/^(?:\+|-(?![}%#]\}))/))) {
1705
+ const right = this.parseMathMulDiv();
1706
+ if (!right) throw new Error("Expected right side of 'math plus/minus' expression");
1707
+ left = new BinaryOpExpr(this.getLocation(), left, right, opStr === '+' ? 'Add' : 'Sub');
1708
+ }
1709
+ return left;
1710
+ }
1711
+
1712
+ parseMathMulDiv() {
1713
+ let left = this.parseMathUnaryPlusMinus();
1714
+ if (!left) throw new Error("Expected left side of 'math mul/div' expression");
1715
+ let opStr;
1716
+ while ((opStr = this.consumeToken(/^(?:\*\*?|\/\/?|%(?!\}))/))) {
1717
+ const right = this.parseMathUnaryPlusMinus();
1718
+ if (!right) throw new Error("Expected right side of 'math mul/div' expression");
1719
+ let op;
1720
+ if (opStr === '*') op = 'Mul';
1721
+ else if (opStr === '**') op = 'MulMul';
1722
+ else if (opStr === '/') op = 'Div';
1723
+ else if (opStr === '//') op = 'DivDiv';
1724
+ else op = 'Mod';
1725
+ left = new BinaryOpExpr(this.getLocation(), left, right, op);
1726
+ }
1727
+ if (this.consumeToken('|')) {
1728
+ const expr = this.parseMathMulDiv();
1729
+ if (expr instanceof FilterExpr) { expr.prepend(left); return expr; }
1730
+ return new FilterExpr(this.getLocation(), [left, expr]);
1731
+ }
1732
+ return left;
1733
+ }
1734
+
1735
+ parseMathUnaryPlusMinus() {
1736
+ const opStr = this.consumeToken(/^(?:\+|-(?![}%#]\}))/);
1737
+ const expr = this.parseExpansion();
1738
+ if (!expr) throw new Error("Expected expr of 'unary plus/minus/expansion' expression");
1739
+ if (opStr) return new UnaryOpExpr(this.getLocation(), expr, opStr === '+' ? 'Plus' : 'Minus');
1740
+ return expr;
1741
+ }
1742
+
1743
+ parseExpansion() {
1744
+ const opStr = this.consumeToken(/^\*\*?/);
1745
+ const expr = this.parseValueExpression();
1746
+ if (!opStr) return expr;
1747
+ if (!expr) throw new Error("Expected expr of 'expansion' expression");
1748
+ return new UnaryOpExpr(this.getLocation(), expr, opStr === '*' ? 'Expansion' : 'ExpansionDict');
1749
+ }
1750
+
1751
+ parseValueExpression() {
1752
+ const parseValue = () => {
1753
+ const loc = this.getLocation();
1754
+ const constant = this.parseConstant();
1755
+ if (constant) return new LiteralExpr(loc, constant);
1756
+ if (this.consumeToken(/^null\b/)) return new LiteralExpr(loc, new Value());
1757
+ const identifier = this.parseIdentifier();
1758
+ if (identifier) return identifier;
1759
+ const braced = this.parseBracedExpressionOrArray();
1760
+ if (braced) return braced;
1761
+ const array = this.parseArray();
1762
+ if (array) return array;
1763
+ const dictionary = this.parseDictionary();
1764
+ if (dictionary) return dictionary;
1765
+ throw new Error('Expected value expression');
1766
+ };
1767
+
1768
+ let value = parseValue();
1769
+
1770
+ while (this.pos < this.source.length && this.consumeSpaces() && this.peekSymbols(['[', '.', '('])) {
1771
+ if (this.consumeToken('[')) {
1772
+ const sliceLoc = this.getLocation();
1773
+ let start = null, end = null, step = null;
1774
+ let hasFirstColon = false, hasSecondColon = false;
1775
+
1776
+ if (!this.peekSymbols([':'])) start = this.parseExpression();
1777
+
1778
+ if (this.consumeToken(':')) {
1779
+ hasFirstColon = true;
1780
+ if (!this.peekSymbols([':', ']'])) end = this.parseExpression();
1781
+ if (this.consumeToken(':')) {
1782
+ hasSecondColon = true;
1783
+ if (!this.peekSymbols([']'])) step = this.parseExpression();
1784
+ }
1785
+ }
1786
+
1787
+ let index;
1788
+ if (hasFirstColon || hasSecondColon) index = new SliceExpr(sliceLoc, start, end, step);
1789
+ else index = start;
1790
+ if (!index) throw new Error('Empty index in subscript');
1791
+ if (!this.consumeToken(']')) throw new Error('Expected closing bracket in subscript');
1792
+ value = new SubscriptExpr(value.location, value, index);
1793
+ } else if (this.consumeToken('.')) {
1794
+ const identifier = this.parseIdentifier();
1795
+ if (!identifier) throw new Error('Expected identifier in subscript');
1796
+ this.consumeSpaces();
1797
+ if (this.peekSymbols(['('])) {
1798
+ value = new MethodCallExpr(identifier.location, value, identifier, this.parseCallArgs());
1799
+ } else {
1800
+ value = new SubscriptExpr(identifier.location, value, new LiteralExpr(identifier.location, Value.fromJS(identifier.getName())));
1801
+ }
1802
+ } else if (this.peekSymbols(['('])) {
1803
+ const loc = this.getLocation();
1804
+ value = new CallExpr(loc, value, this.parseCallArgs());
1805
+ }
1806
+ this.consumeSpaces();
1807
+ }
1808
+ return value;
1809
+ }
1810
+
1811
+ parseCallArgs() {
1812
+ this.consumeSpaces();
1813
+ if (!this.consumeToken('(')) throw new Error('Expected opening parenthesis in call args');
1814
+ const result = new ArgumentsExpression();
1815
+ while (this.pos < this.source.length) {
1816
+ if (this.consumeToken(')')) return result;
1817
+ const expr = this.parseExpression();
1818
+ if (!expr) throw new Error('Expected expression in call args');
1819
+ if (expr instanceof VariableExpr) {
1820
+ if (this.consumeToken('=')) {
1821
+ const value = this.parseExpression();
1822
+ if (!value) throw new Error('Expected expression in for named arg');
1823
+ result.kwargs.push([expr.getName(), value]);
1824
+ } else {
1825
+ result.args.push(expr);
1826
+ }
1827
+ } else {
1828
+ result.args.push(expr);
1829
+ }
1830
+ if (!this.consumeToken(',')) {
1831
+ if (!this.consumeToken(')')) throw new Error('Expected closing parenthesis in call args');
1832
+ return result;
1833
+ }
1834
+ }
1835
+ throw new Error('Expected closing parenthesis in call args');
1836
+ }
1837
+
1838
+ parseParameters() {
1839
+ this.consumeSpaces();
1840
+ if (!this.consumeToken('(')) throw new Error('Expected opening parenthesis in param list');
1841
+ const result = [];
1842
+ while (this.pos < this.source.length) {
1843
+ if (this.consumeToken(')')) return result;
1844
+ const expr = this.parseExpression();
1845
+ if (!expr) throw new Error('Expected expression in call args');
1846
+ if (expr instanceof VariableExpr) {
1847
+ if (this.consumeToken('=')) {
1848
+ const value = this.parseExpression();
1849
+ if (!value) throw new Error('Expected expression in for named arg');
1850
+ result.push([expr.getName(), value]);
1851
+ } else {
1852
+ result.push([expr.getName(), null]);
1853
+ }
1854
+ } else {
1855
+ result.push(['', expr]);
1856
+ }
1857
+ if (!this.consumeToken(',')) {
1858
+ if (!this.consumeToken(')')) throw new Error('Expected closing parenthesis in call args');
1859
+ return result;
1860
+ }
1861
+ }
1862
+ throw new Error('Expected closing parenthesis in call args');
1863
+ }
1864
+
1865
+ parseBracedExpressionOrArray() {
1866
+ if (!this.consumeToken('(')) return null;
1867
+ const expr = this.parseExpression();
1868
+ if (!expr) throw new Error('Expected expression in braced expression');
1869
+ if (this.consumeToken(')')) return expr;
1870
+ const tuple = [expr];
1871
+ while (this.pos < this.source.length) {
1872
+ if (!this.consumeToken(',')) throw new Error('Expected comma in tuple');
1873
+ const next = this.parseExpression();
1874
+ if (!next) throw new Error('Expected expression in tuple');
1875
+ tuple.push(next);
1876
+ if (this.consumeToken(')')) return new ArrayExpr(this.getLocation(), tuple);
1877
+ }
1878
+ throw new Error('Expected closing parenthesis');
1879
+ }
1880
+
1881
+ parseArray() {
1882
+ if (!this.consumeToken('[')) return null;
1883
+ const elements = [];
1884
+ if (this.consumeToken(']')) return new ArrayExpr(this.getLocation(), elements);
1885
+ elements.push(this.parseExpression());
1886
+ while (this.pos < this.source.length) {
1887
+ if (this.consumeToken(',')) { elements.push(this.parseExpression()); }
1888
+ else if (this.consumeToken(']')) { return new ArrayExpr(this.getLocation(), elements); }
1889
+ else { throw new Error('Expected comma or closing bracket in array'); }
1890
+ }
1891
+ throw new Error('Expected closing bracket');
1892
+ }
1893
+
1894
+ parseDictionary() {
1895
+ if (!this.consumeToken('{')) return null;
1896
+ const elements = [];
1897
+ if (this.consumeToken('}')) return new DictExpr(this.getLocation(), elements);
1898
+ const parseKV = () => {
1899
+ const key = this.parseExpression();
1900
+ if (!key) throw new Error('Expected key in dictionary');
1901
+ if (!this.consumeToken(':')) throw new Error('Expected colon betweek key & value in dictionary');
1902
+ const value = this.parseExpression();
1903
+ if (!value) throw new Error('Expected value in dictionary');
1904
+ elements.push([key, value]);
1905
+ };
1906
+ parseKV();
1907
+ while (this.pos < this.source.length) {
1908
+ if (this.consumeToken(',')) { parseKV(); }
1909
+ else if (this.consumeToken('}')) { return new DictExpr(this.getLocation(), elements); }
1910
+ else { throw new Error('Expected comma or closing brace in dictionary'); }
1911
+ }
1912
+ throw new Error('Expected closing brace');
1913
+ }
1914
+
1915
+ parseVarNames() {
1916
+ const group = this.consumeTokenGroups(/^((?:\w+)(?:\s*,\s*(?:\w+))*)\s*/);
1917
+ if (!group.length) throw new Error('Expected variable names');
1918
+ return group[1].split(',').map(s => s.trim());
1919
+ }
1920
+
1921
+ // ── Tokenizer ───────────────────────────────────────────────────────
1922
+
1923
+ tokenize() {
1924
+ const tokens = [];
1925
+ try {
1926
+ while (this.pos < this.source.length) {
1927
+ const location = this.getLocation();
1928
+ let group;
1929
+
1930
+ if ((group = this.consumeTokenGroups(/^\{#(-?)([\s\S]*?)(-?)#\}/, 'Keep')).length) {
1931
+ tokens.push({ type: 'Comment', location, preSpace: group[1] === '-' ? 'Strip' : 'Keep', postSpace: group[3] === '-' ? 'Strip' : 'Keep', text: group[2] });
1932
+ } else if ((group = this.consumeTokenGroups(/^\{\{(-?)/, 'Keep')).length) {
1933
+ const preSpace = group[1] === '-' ? 'Strip' : 'Keep';
1934
+ const expr = this.parseExpression();
1935
+ group = this.consumeTokenGroups(/^\s*(-?)\}\}/);
1936
+ if (!group.length) throw new Error('Expected closing expression tag');
1937
+ tokens.push({ type: 'Expression', location, preSpace, postSpace: group[1] === '-' ? 'Strip' : 'Keep', expr });
1938
+ } else if ((group = this.consumeTokenGroups(/^\{%(-?)\s*/, 'Keep')).length) {
1939
+ const preSpace = group[1] === '-' ? 'Strip' : 'Keep';
1940
+ const parseBlockClose = () => {
1941
+ const g = this.consumeTokenGroups(/^\s*(-?)%\}/);
1942
+ if (!g.length) throw new Error('Expected closing block tag');
1943
+ return g[1] === '-' ? 'Strip' : 'Keep';
1944
+ };
1945
+ const keyword = this.consumeToken(/^(?:if|else|elif|endif|for|endfor|generation|endgeneration|set|endset|block|endblock|macro|endmacro|filter|endfilter|break|continue|call|endcall)\b/);
1946
+ if (!keyword) throw new Error('Expected block keyword');
1947
+
1948
+ if (keyword === 'if') {
1949
+ const condition = this.parseExpression();
1950
+ tokens.push({ type: 'If', location, preSpace, postSpace: parseBlockClose(), condition });
1951
+ } else if (keyword === 'elif') {
1952
+ const condition = this.parseExpression();
1953
+ tokens.push({ type: 'Elif', location, preSpace, postSpace: parseBlockClose(), condition });
1954
+ } else if (keyword === 'else') {
1955
+ tokens.push({ type: 'Else', location, preSpace, postSpace: parseBlockClose() });
1956
+ } else if (keyword === 'endif') {
1957
+ tokens.push({ type: 'EndIf', location, preSpace, postSpace: parseBlockClose() });
1958
+ } else if (keyword === 'for') {
1959
+ const varNames = this.parseVarNames();
1960
+ if (!this.consumeToken(/^in\b/)) throw new Error("Expected 'in' keyword in for block");
1961
+ const iterable = this.parseExpression(false);
1962
+ let condition = null;
1963
+ if (this.consumeToken(/^if\b/)) condition = this.parseExpression();
1964
+ const recursive = !!this.consumeToken(/^recursive\b/);
1965
+ tokens.push({ type: 'For', location, preSpace, postSpace: parseBlockClose(), varNames, iterable, condition, recursive });
1966
+ } else if (keyword === 'endfor') {
1967
+ tokens.push({ type: 'EndFor', location, preSpace, postSpace: parseBlockClose() });
1968
+ } else if (keyword === 'generation') {
1969
+ tokens.push({ type: 'Generation', location, preSpace, postSpace: parseBlockClose() });
1970
+ } else if (keyword === 'endgeneration') {
1971
+ tokens.push({ type: 'EndGeneration', location, preSpace, postSpace: parseBlockClose() });
1972
+ } else if (keyword === 'set') {
1973
+ let ns = '', varNames, value = null;
1974
+ const nsGroup = this.consumeTokenGroups(/^(\w+)\s*\.\s*(\w+)/);
1975
+ if (nsGroup.length) {
1976
+ ns = nsGroup[1]; varNames = [nsGroup[2]];
1977
+ if (!this.consumeToken('=')) throw new Error('Expected equals sign in set block');
1978
+ value = this.parseExpression();
1979
+ } else {
1980
+ varNames = this.parseVarNames();
1981
+ if (this.consumeToken('=')) value = this.parseExpression();
1982
+ }
1983
+ tokens.push({ type: 'Set', location, preSpace, postSpace: parseBlockClose(), ns, varNames, value });
1984
+ } else if (keyword === 'endset') {
1985
+ tokens.push({ type: 'EndSet', location, preSpace, postSpace: parseBlockClose() });
1986
+ } else if (keyword === 'macro') {
1987
+ const name = this.parseIdentifier();
1988
+ if (!name) throw new Error('Expected macro name');
1989
+ const params = this.parseParameters();
1990
+ tokens.push({ type: 'Macro', location, preSpace, postSpace: parseBlockClose(), name, params });
1991
+ } else if (keyword === 'endmacro') {
1992
+ tokens.push({ type: 'EndMacro', location, preSpace, postSpace: parseBlockClose() });
1993
+ } else if (keyword === 'call') {
1994
+ const expr = this.parseExpression();
1995
+ tokens.push({ type: 'Call', location, preSpace, postSpace: parseBlockClose(), expr });
1996
+ } else if (keyword === 'endcall') {
1997
+ tokens.push({ type: 'EndCall', location, preSpace, postSpace: parseBlockClose() });
1998
+ } else if (keyword === 'filter') {
1999
+ const filter = this.parseExpression();
2000
+ tokens.push({ type: 'Filter', location, preSpace, postSpace: parseBlockClose(), filter });
2001
+ } else if (keyword === 'endfilter') {
2002
+ tokens.push({ type: 'EndFilter', location, preSpace, postSpace: parseBlockClose() });
2003
+ } else if (keyword === 'break' || keyword === 'continue') {
2004
+ tokens.push({ type: keyword === 'break' ? 'Break' : 'Continue', location, preSpace, postSpace: parseBlockClose(), controlType: keyword === 'break' ? LoopControlType.Break : LoopControlType.Continue });
2005
+ } else if (keyword === 'block' || keyword === 'endblock') {
2006
+ parseBlockClose(); // skip
2007
+ } else {
2008
+ throw new Error('Unexpected block: ' + keyword);
2009
+ }
2010
+ } else {
2011
+ const remaining = this.source.substring(this.pos);
2012
+ const match = remaining.match(/\{\{|\{%|\{#/);
2013
+ if (match && match.index > 0) {
2014
+ tokens.push({ type: 'Text', location, preSpace: 'Keep', postSpace: 'Keep', text: remaining.substring(0, match.index) });
2015
+ this.pos += match.index;
2016
+ } else if (match && match.index === 0) {
2017
+ if (match[0] === '{#') throw new Error('Missing end of comment tag');
2018
+ throw new Error('Internal error: Expected a comment');
2019
+ } else {
2020
+ tokens.push({ type: 'Text', location, preSpace: 'Keep', postSpace: 'Keep', text: remaining });
2021
+ this.pos = this.source.length;
2022
+ }
2023
+ }
2024
+ }
2025
+ return tokens;
2026
+ } catch (e) {
2027
+ if (!e._hasLocation) {
2028
+ e.message += errorLocationSuffix(this.source, this.pos);
2029
+ e._hasLocation = true;
2030
+ }
2031
+ throw e;
2032
+ }
2033
+ }
2034
+
2035
+ // ── Template builder ────────────────────────────────────────────────
2036
+
2037
+ buildTemplate(tokens, state, fully = false) {
2038
+ const children = [];
2039
+ while (state.pos < tokens.length) {
2040
+ const startPos = state.pos;
2041
+ const token = tokens[state.pos++];
2042
+
2043
+ if (token.type === 'If') {
2044
+ const cascade = [[token.condition, this.buildTemplate(tokens, state)]];
2045
+ while (state.pos < tokens.length && tokens[state.pos].type === 'Elif') {
2046
+ const et = tokens[state.pos++];
2047
+ cascade.push([et.condition, this.buildTemplate(tokens, state)]);
2048
+ }
2049
+ if (state.pos < tokens.length && tokens[state.pos].type === 'Else') {
2050
+ state.pos++;
2051
+ cascade.push([null, this.buildTemplate(tokens, state)]);
2052
+ }
2053
+ if (state.pos >= tokens.length || tokens[state.pos++].type !== 'EndIf') {
2054
+ throw new Error('Unterminated if' + errorLocationSuffix(this.source, tokens[startPos].location.pos));
2055
+ }
2056
+ children.push(new IfNode(token.location, cascade));
2057
+ } else if (token.type === 'For') {
2058
+ const body = this.buildTemplate(tokens, state);
2059
+ let elseBody = null;
2060
+ if (state.pos < tokens.length && tokens[state.pos].type === 'Else') {
2061
+ state.pos++;
2062
+ elseBody = this.buildTemplate(tokens, state);
2063
+ }
2064
+ if (state.pos >= tokens.length || tokens[state.pos++].type !== 'EndFor') {
2065
+ throw new Error('Unterminated for' + errorLocationSuffix(this.source, tokens[startPos].location.pos));
2066
+ }
2067
+ children.push(new ForNode(token.location, token.varNames, token.iterable, token.condition, body, token.recursive, elseBody));
2068
+ } else if (token.type === 'Generation') {
2069
+ const body = this.buildTemplate(tokens, state);
2070
+ if (state.pos >= tokens.length || tokens[state.pos++].type !== 'EndGeneration') {
2071
+ throw new Error('Unterminated generation' + errorLocationSuffix(this.source, tokens[startPos].location.pos));
2072
+ }
2073
+ children.push(body);
2074
+ } else if (token.type === 'Text') {
2075
+ const prevToken = state.pos - 1 > 0 ? tokens[state.pos - 2] : null;
2076
+ const nextToken = state.pos < tokens.length ? tokens[state.pos] : null;
2077
+ const preSpace = prevToken ? prevToken.postSpace : 'Keep';
2078
+ const postSpace = nextToken ? nextToken.preSpace : 'Keep';
2079
+
2080
+ let text = token.text;
2081
+
2082
+ if (postSpace === 'Strip') {
2083
+ text = text.replace(/\s+$/, '');
2084
+ } else if (this.options.lstrip_blocks && nextToken) {
2085
+ let i = text.length;
2086
+ while (i > 0 && (text[i - 1] === ' ' || text[i - 1] === '\t')) i--;
2087
+ if ((i === 0 && !prevToken) || (i > 0 && text[i - 1] === '\n')) {
2088
+ text = text.substring(0, i);
2089
+ }
2090
+ }
2091
+
2092
+ if (preSpace === 'Strip') {
2093
+ text = text.replace(/^\s+/, '');
2094
+ } else if (this.options.trim_blocks && prevToken && prevToken.type !== 'Expression') {
2095
+ if (text.length > 0 && text[0] === '\n') text = text.substring(1);
2096
+ }
2097
+
2098
+ if (state.pos >= tokens.length && !this.options.keep_trailing_newline) {
2099
+ let i = text.length;
2100
+ if (i > 0 && text[i - 1] === '\n') {
2101
+ i--;
2102
+ if (i > 0 && text[i - 1] === '\r') i--;
2103
+ text = text.substring(0, i);
2104
+ }
2105
+ }
2106
+
2107
+ children.push(new TextNode(token.location, text));
2108
+ } else if (token.type === 'Expression') {
2109
+ children.push(new ExpressionNode(token.location, token.expr));
2110
+ } else if (token.type === 'Set') {
2111
+ if (token.value) {
2112
+ children.push(new SetNode(token.location, token.ns, token.varNames, token.value));
2113
+ } else {
2114
+ const valueTemplate = this.buildTemplate(tokens, state);
2115
+ if (state.pos >= tokens.length || tokens[state.pos++].type !== 'EndSet') {
2116
+ throw new Error('Unterminated set' + errorLocationSuffix(this.source, tokens[startPos].location.pos));
2117
+ }
2118
+ children.push(new SetTemplateNode(token.location, token.varNames[0], valueTemplate));
2119
+ }
2120
+ } else if (token.type === 'Macro') {
2121
+ const body = this.buildTemplate(tokens, state);
2122
+ if (state.pos >= tokens.length || tokens[state.pos++].type !== 'EndMacro') {
2123
+ throw new Error('Unterminated macro' + errorLocationSuffix(this.source, tokens[startPos].location.pos));
2124
+ }
2125
+ children.push(new MacroNode(token.location, token.name, token.params, body));
2126
+ } else if (token.type === 'Call') {
2127
+ const body = this.buildTemplate(tokens, state);
2128
+ if (state.pos >= tokens.length || tokens[state.pos++].type !== 'EndCall') {
2129
+ throw new Error('Unterminated call' + errorLocationSuffix(this.source, tokens[startPos].location.pos));
2130
+ }
2131
+ children.push(new CallNode(token.location, token.expr, body));
2132
+ } else if (token.type === 'Filter') {
2133
+ const body = this.buildTemplate(tokens, state);
2134
+ if (state.pos >= tokens.length || tokens[state.pos++].type !== 'EndFilter') {
2135
+ throw new Error('Unterminated filter' + errorLocationSuffix(this.source, tokens[startPos].location.pos));
2136
+ }
2137
+ children.push(new FilterNode(token.location, token.filter, body));
2138
+ } else if (token.type === 'Comment') {
2139
+ // skip
2140
+ } else if (token.type === 'Break' || token.type === 'Continue') {
2141
+ children.push(new LoopControlNode(token.location, token.controlType));
2142
+ } else if (['EndFor', 'EndSet', 'EndMacro', 'EndCall', 'EndFilter', 'EndIf', 'Else', 'EndGeneration', 'Elif'].includes(token.type)) {
2143
+ state.pos--;
2144
+ break;
2145
+ } else {
2146
+ throw new Error('Unexpected ' + token.type + errorLocationSuffix(this.source, token.location.pos));
2147
+ }
2148
+ }
2149
+
2150
+ if (fully && state.pos < tokens.length) {
2151
+ const tok = tokens[state.pos];
2152
+ throw new Error('Unexpected ' + tok.type.toLowerCase() + errorLocationSuffix(this.source, tok.location.pos));
2153
+ }
2154
+
2155
+ if (children.length === 0) return new TextNode({ source: this.source, pos: 0 }, '');
2156
+ if (children.length === 1) return children[0];
2157
+ return new SequenceNode(children[0]._location, children);
2158
+ }
2159
+
2160
+ static parse(templateStr, options = {}) {
2161
+ const opts = {
2162
+ trim_blocks: options.trimBlocks || false,
2163
+ lstrip_blocks: options.lstripBlocks || false,
2164
+ keep_trailing_newline: options.keepTrailingNewline || false,
2165
+ };
2166
+ const normalized = templateStr.replace(/\r\n/g, '\n');
2167
+ const parser = new Parser(normalized, opts);
2168
+ const tokens = parser.tokenize();
2169
+ return parser.buildTemplate(tokens, { pos: 0 }, true);
2170
+ }
2171
+ }
2172
+
2173
+ // ── Builtins ────────────────────────────────────────────────────────────
2174
+
2175
+ function simpleFunction(fnName, params, fn) {
2176
+ const namedPositions = new Map();
2177
+ for (let i = 0; i < params.length; i++) namedPositions.set(params[i], i);
2178
+
2179
+ return Value.callable((context, args) => {
2180
+ const argsObj = Value.object();
2181
+ const provided = new Array(params.length).fill(false);
2182
+ for (let i = 0; i < args.args.length; i++) {
2183
+ if (i < params.length) { argsObj.set(params[i], args.args[i]); provided[i] = true; }
2184
+ else throw new Error('Too many positional params for ' + fnName);
2185
+ }
2186
+ for (const [name, value] of args.kwargs) {
2187
+ const pos = namedPositions.get(name);
2188
+ if (pos === undefined) throw new Error('Unknown argument ' + name + ' for function ' + fnName);
2189
+ provided[pos] = true;
2190
+ argsObj.set(name, value);
2191
+ }
2192
+ return fn(context, argsObj);
2193
+ });
2194
+ }
2195
+
2196
+ function _createBuiltins() {
2197
+ const globals = Value.object();
2198
+
2199
+ globals.set('raise_exception', simpleFunction('raise_exception', ['message'], (_ctx, args) => {
2200
+ throw new Error(args.get('message').value);
2201
+ }));
2202
+
2203
+ globals.set('tojson', simpleFunction('tojson', ['value', 'indent', 'ensure_ascii'], (_ctx, args) => {
2204
+ const indent = args.contains('indent') && !args.get('indent').isNull() ? args.get('indent').value : -1;
2205
+ return Value.fromJS(args.get('value').dump(indent, true));
2206
+ }));
2207
+
2208
+ globals.set('items', simpleFunction('items', ['object'], (_ctx, args) => {
2209
+ const items = Value.array();
2210
+ if (args.contains('object')) {
2211
+ const obj = args.get('object');
2212
+ if (!obj.isObject()) throw new Error('Can only get item pairs from a mapping');
2213
+ for (const key of obj.keys()) items.pushBack(Value.array([key, obj.at(key)]));
2214
+ }
2215
+ return items;
2216
+ }));
2217
+
2218
+ globals.set('last', simpleFunction('last', ['items'], (_ctx, args) => {
2219
+ const items = args.get('items');
2220
+ if (!items.isArray()) throw new Error('object is not a list');
2221
+ if (items.empty()) return new Value();
2222
+ return items.at(items.size - 1);
2223
+ }));
2224
+
2225
+ globals.set('first', simpleFunction('first', ['items'], (_ctx, args) => {
2226
+ const items = args.get('items');
2227
+ if (!items.isArray()) throw new Error('object is not a list');
2228
+ if (items.empty()) return new Value();
2229
+ return items.at(0);
2230
+ }));
2231
+
2232
+ globals.set('trim', simpleFunction('trim', ['text'], (_ctx, args) => {
2233
+ const text = args.get('text');
2234
+ return text.isNull() ? text : Value.fromJS(strip(text.value));
2235
+ }));
2236
+
2237
+ globals.set('capitalize', simpleFunction('capitalize', ['text'], (_ctx, args) => {
2238
+ const text = args.get('text');
2239
+ if (text.isNull()) return text;
2240
+ if (!text.isString()) throw new Error('Type must be string, but is ' + typeof text.value);
2241
+ return Value.fromJS(capitalize(text.value));
2242
+ }));
2243
+
2244
+ const charTf = (name, fn) => simpleFunction(name, ['text'], (_ctx, args) => {
2245
+ const t = args.get('text');
2246
+ return t.isNull() ? t : Value.fromJS(fn(t.value));
2247
+ });
2248
+
2249
+ globals.set('lower', charTf('lower', s => s.toLowerCase()));
2250
+ globals.set('upper', charTf('upper', s => s.toUpperCase()));
2251
+
2252
+ globals.set('default', Value.callable((_ctx, args) => {
2253
+ args.expectArgs('default', [2, 3], [0, 1]);
2254
+ const value = args.args[0], defaultValue = args.args[1];
2255
+ let boolean = false;
2256
+ if (args.args.length === 3) boolean = args.args[2].value;
2257
+ else { const bv = args.getNamed('boolean'); if (!bv.isNull()) boolean = bv.value; }
2258
+ return boolean ? (value.toBool() ? value : defaultValue) : (value.isNull() ? defaultValue : value);
2259
+ }));
2260
+
2261
+ const escape = simpleFunction('escape', ['text'], (_ctx, args) => Value.fromJS(htmlEscape(args.get('text').value)));
2262
+ globals.set('e', escape);
2263
+ globals.set('escape', escape);
2264
+
2265
+ globals.set('joiner', simpleFunction('joiner', ['sep'], (_ctx, args) => {
2266
+ const sep = args.contains('sep') && !args.get('sep').isNull() ? args.get('sep').value : '';
2267
+ let first = true;
2268
+ return simpleFunction('', [], () => {
2269
+ if (first) { first = false; return Value.fromJS(''); }
2270
+ return Value.fromJS(sep);
2271
+ });
2272
+ }));
2273
+
2274
+ globals.set('count', simpleFunction('count', ['items'], (_ctx, args) => Value.fromJS(args.get('items').size)));
2275
+
2276
+ globals.set('dictsort', simpleFunction('dictsort', ['value'], (_ctx, args) => {
2277
+ const v = args.get('value');
2278
+ const keys = v.keys();
2279
+ keys.sort((a, b) => a.lt(b) ? -1 : a.gt(b) ? 1 : 0);
2280
+ const res = Value.array();
2281
+ for (const k of keys) res.pushBack(Value.array([k, v.at(k)]));
2282
+ return res;
2283
+ }));
2284
+
2285
+ globals.set('join', simpleFunction('join', ['items', 'd'], (_ctx, args) => {
2286
+ const doJoin = (items, sep) => {
2287
+ if (!items.isArray()) throw new Error('object is not iterable: ' + items.dump());
2288
+ let r = '';
2289
+ for (let i = 0; i < items.size; i++) { if (i) r += sep; r += items.at(i).toStr(); }
2290
+ return Value.fromJS(r);
2291
+ };
2292
+ const sep = args.contains('d') && !args.get('d').isNull() ? args.get('d').value : '';
2293
+ if (args.contains('items')) return doJoin(args.get('items'), sep);
2294
+ return simpleFunction('', ['items'], (_c, a) => doJoin(a.get('items'), sep));
2295
+ }));
2296
+
2297
+ globals.set('namespace', Value.callable((_ctx, args) => {
2298
+ const ns = Value.object();
2299
+ for (const [name, value] of args.kwargs) ns.set(name, value);
2300
+ return ns;
2301
+ }));
2302
+
2303
+ const equalto = simpleFunction('equalto', ['expected', 'actual'], (_ctx, args) => Value.fromJS(args.get('actual').eq(args.get('expected'))));
2304
+ globals.set('equalto', equalto);
2305
+ globals.set('==', equalto);
2306
+
2307
+ globals.set('length', simpleFunction('length', ['items'], (_ctx, args) => Value.fromJS(args.get('items').size)));
2308
+ globals.set('safe', simpleFunction('safe', ['value'], (_ctx, args) => Value.fromJS(args.get('value').toStr())));
2309
+ globals.set('string', simpleFunction('string', ['value'], (_ctx, args) => Value.fromJS(args.get('value').toStr())));
2310
+ globals.set('int', simpleFunction('int', ['value'], (_ctx, args) => Value.fromJS(args.get('value').toInt())));
2311
+ globals.set('list', simpleFunction('list', ['items'], (_ctx, args) => {
2312
+ const items = args.get('items');
2313
+ if (!items.isArray()) throw new Error('object is not iterable');
2314
+ return items;
2315
+ }));
2316
+ globals.set('in', simpleFunction('in', ['item', 'items'], (_ctx, args) => Value.fromJS(valueIn(args.get('item'), args.get('items')))));
2317
+ globals.set('unique', simpleFunction('unique', ['items'], (_ctx, args) => {
2318
+ const items = args.get('items');
2319
+ if (!items.isArray()) throw new Error('object is not iterable');
2320
+ const seen = new Set(), result = Value.array();
2321
+ for (let i = 0; i < items.size; i++) {
2322
+ const item = items.at(i);
2323
+ if (!item.isHashable()) throw new Error('Unsupported type for hashing: ' + item.dump());
2324
+ const key = item.dump();
2325
+ if (!seen.has(key)) { seen.add(key); result.pushBack(item); }
2326
+ }
2327
+ return result;
2328
+ }));
2329
+
2330
+ const makeFilter = (filter, extraArgs) => simpleFunction('', ['value'], (context, args) => {
2331
+ const a = new ArgumentsValue();
2332
+ a.args.push(args.get('value'));
2333
+ for (let i = 0; i < extraArgs.size; i++) a.args.push(extraArgs.at(i));
2334
+ return filter.call(context, a);
2335
+ });
2336
+
2337
+ const selectOrReject = (isSelect) => Value.callable((context, args) => {
2338
+ args.expectArgs(isSelect ? 'select' : 'reject', [2, Infinity], [0, 0]);
2339
+ const items = args.args[0];
2340
+ if (items.isNull()) return Value.array();
2341
+ if (!items.isArray()) throw new Error('object is not iterable: ' + items.dump());
2342
+ const filterFn = context.get(args.args[1]);
2343
+ if (filterFn.isNull()) throw new Error('Undefined filter: ' + args.args[1].dump());
2344
+ const fa = Value.array();
2345
+ for (let i = 2; i < args.args.length; i++) fa.pushBack(args.args[i]);
2346
+ const filter = makeFilter(filterFn, fa);
2347
+ const res = Value.array();
2348
+ for (let i = 0; i < items.size; i++) {
2349
+ const item = items.at(i);
2350
+ const fca = new ArgumentsValue(); fca.args.push(item);
2351
+ if (filter.call(context, fca).toBool() === isSelect) res.pushBack(item);
2352
+ }
2353
+ return res;
2354
+ });
2355
+ globals.set('select', selectOrReject(true));
2356
+ globals.set('reject', selectOrReject(false));
2357
+
2358
+ globals.set('map', Value.callable((context, args) => {
2359
+ const res = Value.array();
2360
+ if (args.args.length === 1 && ((args.hasNamed('attribute') && args.kwargs.length === 1) || (args.hasNamed('default') && args.kwargs.length === 2))) {
2361
+ const items = args.args[0], attrName = args.getNamed('attribute'), defaultValue = args.getNamed('default');
2362
+ for (let i = 0; i < items.size; i++) {
2363
+ const attr = items.at(i).get(attrName);
2364
+ res.pushBack(attr.isNull() ? defaultValue : attr);
2365
+ }
2366
+ } else if (args.kwargs.length === 0 && args.args.length >= 2) {
2367
+ const fn = context.get(args.args[1]);
2368
+ if (fn.isNull()) throw new Error('Undefined filter: ' + args.args[1].dump());
2369
+ const fa = new ArgumentsValue(); fa.args.push(new Value());
2370
+ for (let i = 2; i < args.args.length; i++) fa.args.push(args.args[i]);
2371
+ for (let i = 0; i < args.args[0].size; i++) { fa.args[0] = args.args[0].at(i); res.pushBack(fn.call(context, fa)); }
2372
+ } else throw new Error('Invalid or unsupported arguments for map');
2373
+ return res;
2374
+ }));
2375
+
2376
+ globals.set('indent', simpleFunction('indent', ['text', 'indent', 'first'], (_ctx, args) => {
2377
+ const text = args.get('text').value;
2378
+ const firstIndent = args.contains('first') && !args.get('first').isNull() ? args.get('first').toBool() : false;
2379
+ const indentStr = ' '.repeat(args.contains('indent') && !args.get('indent').isNull() ? args.get('indent').value : 0);
2380
+ // Mimic C++ std::getline behavior: split by \n but don't create trailing empty element
2381
+ let lines = text.split('\n');
2382
+ if (lines.length > 0 && lines[lines.length - 1] === '' && text.endsWith('\n')) {
2383
+ lines = lines.slice(0, -1);
2384
+ }
2385
+ let out = '';
2386
+ for (let i = 0; i < lines.length; i++) {
2387
+ if (i > 0) out += '\n';
2388
+ if (i > 0 || firstIndent) out += indentStr;
2389
+ out += lines[i];
2390
+ }
2391
+ if (text.length > 0 && text[text.length - 1] === '\n') out += '\n';
2392
+ return Value.fromJS(out);
2393
+ }));
2394
+
2395
+ const selectOrRejectAttr = (isSelect) => Value.callable((context, args) => {
2396
+ args.expectArgs(isSelect ? 'selectattr' : 'rejectattr', [2, Infinity], [0, 0]);
2397
+ const items = args.args[0];
2398
+ if (items.isNull()) return Value.array();
2399
+ if (!items.isArray()) throw new Error('object is not iterable: ' + items.dump());
2400
+ const attrName = args.args[1].value;
2401
+ let hasTest = false, testFn;
2402
+ const testArgs = new ArgumentsValue(); testArgs.args.push(new Value());
2403
+ if (args.args.length >= 3) {
2404
+ hasTest = true;
2405
+ testFn = context.get(args.args[2]);
2406
+ if (testFn.isNull()) throw new Error('Undefined test: ' + args.args[2].dump());
2407
+ for (let i = 3; i < args.args.length; i++) testArgs.args.push(args.args[i]);
2408
+ testArgs.kwargs = args.kwargs;
2409
+ }
2410
+ const res = Value.array();
2411
+ for (let i = 0; i < items.size; i++) {
2412
+ const item = items.at(i), attr = item.get(attrName);
2413
+ if (hasTest) { testArgs.args[0] = attr; if (testFn.call(context, testArgs).toBool() === isSelect) res.pushBack(item); }
2414
+ else res.pushBack(attr);
2415
+ }
2416
+ return res;
2417
+ });
2418
+ globals.set('selectattr', selectOrRejectAttr(true));
2419
+ globals.set('rejectattr', selectOrRejectAttr(false));
2420
+
2421
+ globals.set('range', Value.callable((_ctx, args) => {
2422
+ const ses = [0, 0, 1], ps = [false, false, false];
2423
+ if (args.args.length === 1) { ses[1] = args.args[0].value; ps[1] = true; }
2424
+ else { for (let i = 0; i < args.args.length; i++) { ses[i] = args.args[i].value; ps[i] = true; } }
2425
+ for (const [n, v] of args.kwargs) {
2426
+ let i; if (n === 'start') i = 0; else if (n === 'end') i = 1; else if (n === 'step') i = 2;
2427
+ else throw new Error('Unknown argument ' + n + ' for function range');
2428
+ if (ps[i]) throw new Error('Duplicate argument ' + n + ' for function range');
2429
+ ses[i] = v.value; ps[i] = true;
2430
+ }
2431
+ if (!ps[1]) throw new Error("Missing required argument 'end' for function range");
2432
+ const start = ps[0] ? ses[0] : 0, end = ses[1], step = ps[2] ? ses[2] : 1;
2433
+ const res = Value.array();
2434
+ if (step > 0) { for (let i = start; i < end; i += step) res.pushBack(Value.fromJS(i)); }
2435
+ else { for (let i = start; i > end; i += step) res.pushBack(Value.fromJS(i)); }
2436
+ return res;
2437
+ }));
2438
+
2439
+ return new Context(globals);
2440
+ }
2441
+
2442
+ // ── Template Cache ──────────────────────────────────────────────────────
2443
+ //
2444
+ // Parser.parse() builds an AST from a template string. The AST is stateless —
2445
+ // all runtime state lives in the Context passed to render(). That means a
2446
+ // parsed template can be rendered any number of times with different contexts,
2447
+ // and concurrent renders of the same cached AST are safe.
2448
+ //
2449
+ // This cache is keyed on (templateStr + options) so that the same template
2450
+ // parsed with different whitespace-control options gets separate entries.
2451
+ // It uses a plain Map (unbounded) which is fine for the expected use case of
2452
+ // tens-to-hundreds of distinct chat templates in a server process. If you are
2453
+ // dynamically generating template strings, bypass the cache and call
2454
+ // Parser.parse() directly.
2455
+ //
2456
+ const _templateCache = new Map();
2457
+
2458
+ function _cacheKey(templateStr, options) {
2459
+ return `${templateStr}\0${options.trimBlocks ? 1 : 0}${options.lstripBlocks ? 1 : 0}${options.keepTrailingNewline ? 1 : 0}`;
2460
+ }
2461
+
2462
+ /**
2463
+ * Parse a template string into a reusable AST, returning a cached copy if
2464
+ * the same template and options have been parsed before.
2465
+ *
2466
+ * The returned AST is stateless and safe for concurrent use across renders.
2467
+ */
2468
+ function parseTemplate(templateStr, options = {}) {
2469
+ const key = _cacheKey(templateStr, options);
2470
+ let root = _templateCache.get(key);
2471
+ if (!root) {
2472
+ root = Parser.parse(templateStr, options);
2473
+ _templateCache.set(key, root);
2474
+ }
2475
+ return root;
2476
+ }
2477
+
2478
+ /**
2479
+ * Clear the parsed-template cache. Useful in tests or if you want to
2480
+ * reclaim memory after loading a batch of templates you no longer need.
2481
+ */
2482
+ function clearTemplateCache() {
2483
+ _templateCache.clear();
2484
+ }
2485
+
2486
+ export { Parser, Context, Value, parseTemplate, clearTemplateCache };