@mrhenry/twig-parser 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.
@@ -0,0 +1,1103 @@
1
+ // @ts-check
2
+ /**
3
+ * The expression parser: implements a precedence-climbing algorithm.
4
+ *
5
+ * Mirrors `Twig\Parser::parseExpression()` plus the prefix/infix expression
6
+ * parsers from `src/ExpressionParser/*` of the reference implementation.
7
+ * See `spec/03-expressions.md`.
8
+ *
9
+ * @module twig-parser
10
+ */
11
+ import { TokenType } from '@mrhenry/twig-tokenizer';
12
+ import { SyntaxError } from '@mrhenry/twig-tokenizer';
13
+ // eslint-disable-next-line no-unused-vars -- used in JSDoc type annotations
14
+ import { Node, NodeType, n } from './node.js';
15
+ import { ArrayExpression } from './array-expression.js';
16
+
17
+ /**
18
+ * @typedef {import('@mrhenry/twig-tokenizer').Token} Token
19
+ */
20
+
21
+ /** Name pattern used to accept word-operators as variable names. */
22
+ const REGULAR_EXPRESSION_NAME = /^[a-zA-Z_\u007f-\uffff][a-zA-Z0-9_\u007f-\uffff]*$/;
23
+
24
+ /**
25
+ * Binary operator metadata: precedence and associativity.
26
+ *
27
+ * @type {Record<string, {precedence: number, right: boolean}>}
28
+ */
29
+ export const BINARY_INFORMATION = {
30
+ '?:': { precedence: 5, right: true },
31
+ '? :': { precedence: 5, right: true },
32
+ '??': { precedence: 300, right: true },
33
+ or: { precedence: 10, right: false },
34
+ xor: { precedence: 12, right: false },
35
+ and: { precedence: 15, right: false },
36
+ 'b-or': { precedence: 16, right: false },
37
+ 'b-xor': { precedence: 17, right: false },
38
+ 'b-and': { precedence: 18, right: false },
39
+ '==': { precedence: 20, right: false },
40
+ '!=': { precedence: 20, right: false },
41
+ '<=>': { precedence: 20, right: false },
42
+ '<': { precedence: 20, right: false },
43
+ '>': { precedence: 20, right: false },
44
+ '>=': { precedence: 20, right: false },
45
+ '<=': { precedence: 20, right: false },
46
+ 'not in': { precedence: 20, right: false },
47
+ in: { precedence: 20, right: false },
48
+ matches: { precedence: 20, right: false },
49
+ 'starts with': { precedence: 20, right: false },
50
+ 'ends with': { precedence: 20, right: false },
51
+ 'has some': { precedence: 20, right: false },
52
+ 'has every': { precedence: 20, right: false },
53
+ '===': { precedence: 20, right: false },
54
+ '!==': { precedence: 20, right: false },
55
+ '..': { precedence: 25, right: false },
56
+ '+': { precedence: 30, right: false },
57
+ '-': { precedence: 30, right: false },
58
+ '~': { precedence: 40, right: false },
59
+ '*': { precedence: 60, right: false },
60
+ '/': { precedence: 60, right: false },
61
+ '//': { precedence: 60, right: false },
62
+ '%': { precedence: 60, right: false },
63
+ '**': { precedence: 200, right: true },
64
+ '=': { precedence: 0, right: true },
65
+ is: { precedence: 100, right: false },
66
+ 'is not': { precedence: 100, right: false },
67
+ '|': { precedence: 512, right: false },
68
+ '.': { precedence: 512, right: false },
69
+ '?.': { precedence: 512, right: false },
70
+ '[': { precedence: 512, right: false },
71
+ '(': { precedence: 512, right: false },
72
+ '?': { precedence: 0, right: false },
73
+ '=>': { precedence: 250, right: false },
74
+ };
75
+
76
+ /**
77
+ * Prefix (unary) operators: operator name → operand precedence bound.
78
+ *
79
+ * Mirrors the prefix parsers registered by `CoreExtension` (`not`, spread `...`)
80
+ * and `UnaryOperatorExpressionParser` (`-`, `+`).
81
+ *
82
+ * @type {Record<string, number>}
83
+ */
84
+ export const PREFIX_OPERATORS = {
85
+ '...': 0,
86
+ not: 50,
87
+ '-': 500,
88
+ '+': 500,
89
+ };
90
+
91
+ /**
92
+ * Operators that may follow an expression (postfix), beyond the binary set in
93
+ * {@link BINARY_INFORMATION}: function call `(`, subscript `[`, and the
94
+ * attribute operators `.` / `?.` and filter `|`.
95
+ *
96
+ * @type {string[]}
97
+ */
98
+ export const POSTFIX_OPERATORS = ['(', '[', '.', '?.', '|'];
99
+
100
+ /**
101
+ * Functions with required (non-defaulted) arguments that the *parser* enforces.
102
+ *
103
+ * Mirrors the reference implementation: only the parser callables (`block`,
104
+ * `attribute`) are validated at parse time; ordinary functions such as
105
+ * `min`, `max`, `range` and `cycle` defer argument checking to runtime.
106
+ *
107
+ * @type {Record<string, string[]>}
108
+ */
109
+ const REQUIRED_FUNCTION_ARGS = {
110
+ block: ['name'],
111
+ attribute: ['variable', 'attribute'],
112
+ };
113
+
114
+ /**
115
+ * The expression parser.
116
+ *
117
+ * Relies on a host {@link Parser} for the token stream, callable resolution
118
+ * and imported-symbol tracking.
119
+ */
120
+ export class ExpressionParser {
121
+ /**
122
+ * @param {import('./parser.js').Parser} parser The host parser.
123
+ */
124
+ constructor(parser) {
125
+ /** @type {import('./parser.js').Parser} */
126
+ this.parser = parser;
127
+ }
128
+
129
+ /**
130
+ * @returns {import('@mrhenry/twig-tokenizer').TokenStream} The token stream.
131
+ */
132
+ getStream() {
133
+ return this.parser.getStream();
134
+ }
135
+
136
+ /**
137
+ * @returns {Token} The current token.
138
+ */
139
+ current() {
140
+ return this.parser.getCurrentToken();
141
+ }
142
+
143
+ /**
144
+ * Parses an expression at the given precedence bound.
145
+ *
146
+ * @param {number} [precedence] The minimum precedence of infix operators to consume.
147
+ * @returns {Node} The parsed expression node.
148
+ */
149
+ parseExpression(precedence = 0) {
150
+ const stream = this.getStream();
151
+ const token = this.current();
152
+ let expression;
153
+
154
+ if (token.test(TokenType.OPERATOR)) {
155
+ switch (token.value) {
156
+ case '(':
157
+ stream.next();
158
+ expression = this.parseGrouping();
159
+ break;
160
+ case 'not':
161
+ case '-':
162
+ case '+':
163
+ case '...':
164
+ stream.next();
165
+ expression = this.parseUnary(token.value, token);
166
+ break;
167
+ default:
168
+ expression = this.parsePrimary();
169
+ break;
170
+ }
171
+ } else {
172
+ expression = this.parsePrimary();
173
+ }
174
+
175
+ // infix loop
176
+ for (;;) {
177
+ const t = this.current();
178
+ if (!t.test(TokenType.OPERATOR)) {
179
+ break;
180
+ }
181
+ const operator = String(t.value);
182
+ const operatorInfo = BINARY_INFORMATION[operator];
183
+ if (!operatorInfo || operatorInfo.precedence < precedence) {
184
+ break;
185
+ }
186
+ stream.next();
187
+ switch (operator) {
188
+ case '?':
189
+ expression = this.parseConditional(expression);
190
+ break;
191
+ case '=':
192
+ expression = this.parseAssignment(expression);
193
+ break;
194
+ case 'is':
195
+ expression = this.parseTest(expression, false);
196
+ break;
197
+ case 'is not':
198
+ expression = this.parseTest(expression, true);
199
+ break;
200
+ case '|':
201
+ expression = this.parseFilter(expression);
202
+ break;
203
+ case '(':
204
+ expression = this.parseFunction(expression);
205
+ break;
206
+ case '.':
207
+ case '?.':
208
+ expression = this.parseDot(expression, operator);
209
+ break;
210
+ case '[':
211
+ expression = this.parseSquareBracket(expression);
212
+ break;
213
+ case '=>':
214
+ expression = this.parseArrow(expression);
215
+ break;
216
+ default:
217
+ expression = this.parseBinary(operator, expression, operatorInfo);
218
+ break;
219
+ }
220
+ }
221
+
222
+ return expression;
223
+ }
224
+
225
+ /**
226
+ * Parses a prefix unary expression.
227
+ *
228
+ * @param {string} operator The operator (`not`, `-`, `+` or `...`).
229
+ * @param {Token} token The operator token.
230
+ * @returns {Node} A `unary` node.
231
+ */
232
+ parseUnary(operator, token) {
233
+ let operandPrecedence;
234
+ switch (operator) {
235
+ case '...':
236
+ operandPrecedence = 0;
237
+ break;
238
+ case 'not':
239
+ operandPrecedence = 50;
240
+ break;
241
+ default:
242
+ operandPrecedence = 500;
243
+ break;
244
+ }
245
+ const operand = this.parseExpression(operandPrecedence);
246
+ return n(NodeType.Unary, { node: operand }, { operator: operator }, token.getLine());
247
+ }
248
+
249
+ /**
250
+ * Parses a literal primary expression (name, number, string, sequence, mapping).
251
+ *
252
+ * @returns {Node} The literal expression node.
253
+ */
254
+ parsePrimary() {
255
+ const stream = this.getStream();
256
+ const token = this.current();
257
+
258
+ if (token.test(TokenType.NAME)) {
259
+ stream.next();
260
+ switch (token.value) {
261
+ case 'true':
262
+ case 'TRUE':
263
+ return n(NodeType.Constant, {}, { value: true }, token.getLine());
264
+ case 'false':
265
+ case 'FALSE':
266
+ return n(NodeType.Constant, {}, { value: false }, token.getLine());
267
+ case 'none':
268
+ case 'NONE':
269
+ case 'null':
270
+ case 'NULL':
271
+ return n(NodeType.Constant, {}, { value: null }, token.getLine());
272
+ default:
273
+ return n(NodeType.ContextVariable, {}, { name: token.value }, token.getLine());
274
+ }
275
+ }
276
+
277
+ if (token.test(TokenType.NUMBER)) {
278
+ stream.next();
279
+ return n(NodeType.Constant, {}, { value: token.value }, token.getLine());
280
+ }
281
+
282
+ if (token.test(TokenType.STRING) || token.test(TokenType.INTERPOLATION_START)) {
283
+ return this.parseStringExpression();
284
+ }
285
+
286
+ if (token.test(TokenType.PUNCTUATION)) {
287
+ if (token.value === '{') {
288
+ return this.parseMappingExpression();
289
+ }
290
+ }
291
+
292
+ if (token.test(TokenType.OPERATOR)) {
293
+ if (token.value === '[') {
294
+ return this.parseSequenceExpression();
295
+ }
296
+ if (REGULAR_EXPRESSION_NAME.test(String(token.value))) {
297
+ // in this context, string operators are variable names
298
+ stream.next();
299
+ return n(NodeType.ContextVariable, {}, { name: token.value }, token.getLine());
300
+ }
301
+ }
302
+
303
+ throw new SyntaxError(
304
+ `Unexpected token "${token.toEnglish()}" of value "${token.value}".`,
305
+ token.getLine(),
306
+ this.getStream().getSourceContext(),
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Parses a string expression with adjacent fragments and interpolations.
312
+ *
313
+ * @returns {Node} A concatenation of string constants and interpolations.
314
+ */
315
+ parseStringExpression() {
316
+ const stream = this.getStream();
317
+ /** @type {Node[]} */
318
+ const nodes = [];
319
+ let nextCanBeString = true;
320
+ for (;;) {
321
+ const stringToken = nextCanBeString ? stream.nextIf(TokenType.STRING) : null;
322
+ if (stringToken) {
323
+ nodes.push(n(NodeType.Constant, {}, { value: stringToken.value }, stringToken.getLine()));
324
+ nextCanBeString = false;
325
+ } else if (stream.nextIf(TokenType.INTERPOLATION_START)) {
326
+ nodes.push(this.parseExpression());
327
+ stream.expect(TokenType.INTERPOLATION_END);
328
+ nextCanBeString = true;
329
+ } else {
330
+ break;
331
+ }
332
+ }
333
+
334
+ let expression = nodes.shift() ?? n(NodeType.Constant, {}, { value: '' }, this.current().getLine());
335
+ for (const node of nodes) {
336
+ expression = n(NodeType.Binary, { left: expression, right: node }, { operator: '~' }, node.getTemplateLine());
337
+ }
338
+ return expression;
339
+ }
340
+
341
+ /**
342
+ * Parses a sequence literal `[a, b, c]`.
343
+ *
344
+ * @returns {Node} An `array` node.
345
+ */
346
+ parseSequenceExpression() {
347
+ const stream = this.getStream();
348
+ stream.expect(TokenType.OPERATOR, '[', 'A sequence element was expected');
349
+
350
+ const node = this.makeArray(stream.getCurrent().getLine());
351
+ let first = true;
352
+ while (!stream.test(TokenType.PUNCTUATION, ']')) {
353
+ if (!first) {
354
+ stream.expect(TokenType.PUNCTUATION, ',', 'A sequence element must be followed by a comma');
355
+ // trailing comma?
356
+ if (stream.test(TokenType.PUNCTUATION, ']')) {
357
+ break;
358
+ }
359
+ }
360
+ first = false;
361
+
362
+ // empty slot?
363
+ if (stream.test(TokenType.PUNCTUATION, ',')) {
364
+ node.addElement(n(NodeType.Empty, {}, {}, stream.getCurrent().getLine()));
365
+ } else {
366
+ node.addElement(this.parseExpression());
367
+ }
368
+ }
369
+ stream.expect(TokenType.PUNCTUATION, ']', 'An opened sequence is not properly closed');
370
+ return node;
371
+ }
372
+
373
+ /**
374
+ * Parses a mapping literal `{key: value}`.
375
+ *
376
+ * @returns {Node} An `array` node.
377
+ */
378
+ parseMappingExpression() {
379
+ const stream = this.getStream();
380
+ stream.expect(TokenType.PUNCTUATION, '{', 'A mapping element was expected');
381
+
382
+ const node = this.makeArray(stream.getCurrent().getLine());
383
+ let first = true;
384
+ while (!stream.test(TokenType.PUNCTUATION, '}')) {
385
+ if (!first) {
386
+ stream.expect(TokenType.PUNCTUATION, ',', 'A mapping value must be followed by a comma');
387
+ // trailing comma?
388
+ if (stream.test(TokenType.PUNCTUATION, '}')) {
389
+ break;
390
+ }
391
+ }
392
+ first = false;
393
+
394
+ if (stream.test(TokenType.OPERATOR, '...')) {
395
+ node.addElement(this.parseExpression());
396
+ continue;
397
+ }
398
+
399
+ /** @type {Node} */
400
+ let key;
401
+ /** @type {import('@mrhenry/twig-tokenizer').Token|null} */
402
+ let keyToken;
403
+ if ((keyToken = stream.nextIf(TokenType.NAME))) {
404
+ key = n(NodeType.Constant, {}, { value: keyToken.value }, keyToken.getLine());
405
+ // {a} is a shortcut for {a: a}
406
+ if (stream.test(TokenType.PUNCTUATION, [',', '}'])) {
407
+ const value = n(
408
+ NodeType.ContextVariable,
409
+ {},
410
+ { name: key.getAttribute('value') },
411
+ key.getTemplateLine(),
412
+ );
413
+ node.addElement(value, key);
414
+ continue;
415
+ }
416
+ } else if ((keyToken = stream.nextIf(TokenType.STRING)) || (keyToken = stream.nextIf(TokenType.NUMBER))) {
417
+ key = n(NodeType.Constant, {}, { value: keyToken.value }, keyToken.getLine());
418
+ } else if (stream.test(TokenType.OPERATOR, '(')) {
419
+ key = this.parseExpression();
420
+ } else {
421
+ const current = stream.getCurrent();
422
+ throw new SyntaxError(
423
+ `A mapping key must be a quoted string, a number, a name, or an expression enclosed in parentheses (unexpected token "${current.toEnglish()}" of value "${current.value}".`,
424
+ current.getLine(),
425
+ stream.getSourceContext(),
426
+ );
427
+ }
428
+
429
+ stream.expect(TokenType.PUNCTUATION, ':', 'A mapping key must be followed by a colon (:)');
430
+ const value = this.parseExpression();
431
+ node.addElement(value, key);
432
+ }
433
+ stream.expect(TokenType.PUNCTUATION, '}', 'An opened mapping is not properly closed');
434
+ return node;
435
+ }
436
+
437
+ /**
438
+ * Parses a parenthesized grouping or an arrow-function parameter list.
439
+ *
440
+ * @returns {Node} A grouped expression or a `list` node.
441
+ */
442
+ parseGrouping() {
443
+ const stream = this.getStream();
444
+ const token = this.current();
445
+ const expression = this.parseExpression(0);
446
+
447
+ if (stream.nextIf(TokenType.PUNCTUATION, ')')) {
448
+ if (!stream.test(TokenType.OPERATOR, '=>')) {
449
+ return expression.setExplicitParentheses();
450
+ }
451
+ // (x) => ...
452
+ return n(
453
+ NodeType.ListExpr,
454
+ {},
455
+ { names: [this.toAssignContextVariable(expression)] },
456
+ token.getLine(),
457
+ );
458
+ }
459
+
460
+ // arrow function arguments: (value, key) => ...
461
+ if (!stream.test(TokenType.PUNCTUATION, ',')) {
462
+ stream.expect(TokenType.PUNCTUATION, ')', 'An opened parenthesis is not properly closed');
463
+ }
464
+
465
+ /** @type {Node[]} */
466
+ const names = [expression];
467
+ for (;;) {
468
+ if (stream.nextIf(TokenType.PUNCTUATION, ')')) {
469
+ break;
470
+ }
471
+ stream.expect(TokenType.PUNCTUATION, ',');
472
+ const nameToken = stream.expect(TokenType.NAME);
473
+ names.push(n(NodeType.ContextVariable, {}, { name: nameToken.value }, nameToken.getLine()));
474
+ }
475
+
476
+ if (!stream.test(TokenType.OPERATOR, '=>')) {
477
+ throw new SyntaxError(
478
+ 'A list of variables must be followed by an arrow.',
479
+ stream.getCurrent().getLine(),
480
+ stream.getSourceContext(),
481
+ );
482
+ }
483
+
484
+ return n(
485
+ NodeType.ListExpr,
486
+ {},
487
+ { names: names.map((name) => this.toAssignContextVariable(name)) },
488
+ token.getLine(),
489
+ );
490
+ }
491
+
492
+ /**
493
+ * Converts an expression to an assignable context variable.
494
+ *
495
+ * @param {Node} expression
496
+ * @returns {Node} An `assign_context_variable` node.
497
+ */
498
+ toAssignContextVariable(expression) {
499
+ if (expression.type !== NodeType.ContextVariable) {
500
+ throw new SyntaxError(
501
+ 'A list must only contain variables.',
502
+ expression.getTemplateLine(),
503
+ expression.getSourceContext(),
504
+ );
505
+ }
506
+ if (expression.type === NodeType.AssignContextVariable) {
507
+ return expression;
508
+ }
509
+ return n(
510
+ NodeType.AssignContextVariable,
511
+ {},
512
+ { name: expression.getAttribute('name') },
513
+ expression.getTemplateLine(),
514
+ );
515
+ }
516
+
517
+ /**
518
+ * Parses `.` / `?.` attribute access (and macro references).
519
+ *
520
+ * @param {Node} expression The base expression.
521
+ * @param {string} operator The operator (`.` or `?.`).
522
+ * @returns {Node} A `get_attr`, `macro_reference` or `filter` node.
523
+ */
524
+ parseDot(expression, operator) {
525
+ const nullSafe = operator === '?.';
526
+ const stream = this.getStream();
527
+ const token = stream.getCurrent();
528
+ const lineNumber = token.getLine();
529
+
530
+ /** @type {Node} */
531
+ let attribute;
532
+ if (stream.nextIf(TokenType.OPERATOR, '(')) {
533
+ attribute = this.parseExpression();
534
+ stream.expect(TokenType.PUNCTUATION, ')');
535
+ } else {
536
+ const attributeToken = stream.next();
537
+ if (
538
+ attributeToken.test(TokenType.NAME) ||
539
+ attributeToken.test(TokenType.NUMBER) ||
540
+ (attributeToken.test(TokenType.OPERATOR) && REGULAR_EXPRESSION_NAME.test(String(attributeToken.value)))
541
+ ) {
542
+ attribute = n(NodeType.Constant, {}, { value: attributeToken.value }, attributeToken.getLine());
543
+ } else {
544
+ throw new SyntaxError(
545
+ `Expected name or number, got value "${attributeToken.value}" of type "${attributeToken.toEnglish()}".`,
546
+ attributeToken.getLine(),
547
+ stream.getSourceContext(),
548
+ );
549
+ }
550
+ }
551
+
552
+ const isMacroTarget =
553
+ expression.type === NodeType.ContextVariable &&
554
+ (this.parser.getImportedSymbol('template', String(expression.getAttribute('name'))) !== null ||
555
+ expression.getAttribute('name') === '_self');
556
+
557
+ let type = 'any';
558
+ /** @type {import('./array-expression.js').ArrayExpression} */
559
+ let argumentsNode = this.makeArray(lineNumber);
560
+ if (stream.test(TokenType.OPERATOR, '(')) {
561
+ type = 'method';
562
+ // preserve named-argument keys so the runtime can resolve them against
563
+ // the method's parameter names
564
+ argumentsNode = this.parseCallableArguments(lineNumber, true, true);
565
+ }
566
+
567
+ if (isMacroTarget) {
568
+ const node = n(
569
+ NodeType.MacroReference,
570
+ { var: n(NodeType.LocalVariable, {}, { name: expression.getAttribute('name') }, expression.getTemplateLine()), name: attribute, arguments: argumentsNode },
571
+ { hasCallParentheses: type === 'method' },
572
+ expression.getTemplateLine(),
573
+ );
574
+ return node;
575
+ }
576
+
577
+ return n(
578
+ NodeType.GetAttr,
579
+ { node: expression, attribute, arguments: argumentsNode },
580
+ { type, nullSafe },
581
+ lineNumber,
582
+ );
583
+ }
584
+
585
+ /**
586
+ * Parses `[` subscript access (including the `[:]` slice syntax).
587
+ *
588
+ * @param {Node} expression The base expression.
589
+ * @returns {Node} A `get_attr` or `filter` (`slice`) node.
590
+ */
591
+ parseSquareBracket(expression) {
592
+ const stream = this.getStream();
593
+ const token = this.current();
594
+ const lineNumber = token.getLine();
595
+ const argumentsNode = this.makeArray(lineNumber);
596
+
597
+ // slice?
598
+ let slice = false;
599
+ let attribute;
600
+ if (stream.test(TokenType.PUNCTUATION, ':')) {
601
+ slice = true;
602
+ attribute = n(NodeType.Constant, {}, { value: 0 }, token.getLine());
603
+ } else {
604
+ attribute = this.parseExpression();
605
+ }
606
+
607
+ if (stream.nextIf(TokenType.PUNCTUATION, ':')) {
608
+ slice = true;
609
+ }
610
+
611
+ if (slice) {
612
+ let length;
613
+ if (stream.test(TokenType.PUNCTUATION, ']')) {
614
+ length = n(NodeType.Constant, {}, { value: null }, token.getLine());
615
+ } else {
616
+ length = this.parseExpression();
617
+ }
618
+ stream.expect(TokenType.PUNCTUATION, ']');
619
+ return n(
620
+ NodeType.Filter,
621
+ { node: expression, arguments: [ { name: null, value: attribute }, { name: null, value: length } ] },
622
+ { name: 'slice' },
623
+ lineNumber,
624
+ );
625
+ }
626
+
627
+ stream.expect(TokenType.PUNCTUATION, ']');
628
+ return n(
629
+ NodeType.GetAttr,
630
+ { node: expression, attribute, arguments: argumentsNode },
631
+ { type: 'array' },
632
+ lineNumber,
633
+ );
634
+ }
635
+
636
+ /**
637
+ * Parses a filter call chain (`expression|name(args)`).
638
+ *
639
+ * @param {Node} expression The piped value.
640
+ * @returns {Node} A `filter` node.
641
+ */
642
+ parseFilter(expression) {
643
+ const stream = this.getStream();
644
+ const token = stream.expect(TokenType.NAME);
645
+ const line = token.getLine();
646
+
647
+ /** @type {Array<{name: string|null, value: Node}>} */
648
+ let args;
649
+ if (!stream.test(TokenType.OPERATOR, '(')) {
650
+ args = [];
651
+ } else {
652
+ args = this.parseNamedArguments(true, `for filter "${token.value}"`);
653
+ }
654
+
655
+ const filter = this.parser.getFilter(token.value, line);
656
+ if (filter.parameters) {
657
+ this.validateNamedArgs(args, filter.parameters, `for filter "${filter.name}"`);
658
+ }
659
+
660
+ return n(NodeType.Filter, { node: expression, arguments: args }, { name: filter.name }, line);
661
+ }
662
+
663
+ /**
664
+ * Parses a filter chain starting at the given node (used by `apply`).
665
+ *
666
+ * @param {Node} expression The base node (a captured variable).
667
+ * @returns {Node} The filter chain node.
668
+ */
669
+ parseFilterChain(expression) {
670
+ const stream = this.getStream();
671
+ let filter = expression;
672
+ for (;;) {
673
+ filter = this.parseFilter(filter);
674
+ if (!stream.test(TokenType.OPERATOR, '|')) {
675
+ break;
676
+ }
677
+ stream.next();
678
+ }
679
+ return filter;
680
+ }
681
+
682
+ /**
683
+ * Parses a function call (`name(args)`).
684
+ *
685
+ * @param {Node} expression The function-name expression.
686
+ * @returns {Node} A `function` or `macro_reference` node.
687
+ */
688
+ parseFunction(expression) {
689
+ const stream = this.getStream();
690
+ const line = this.current().getLine();
691
+ if (expression.type !== NodeType.ContextVariable) {
692
+ throw new SyntaxError(
693
+ 'Function name must be an identifier.',
694
+ line,
695
+ stream.getSourceContext(),
696
+ );
697
+ }
698
+ const name = String(expression.getAttribute('name'));
699
+
700
+ // a bare call to a macro imported via "from"
701
+ const alias = this.parser.getImportedSymbol('function', name);
702
+ if (alias) {
703
+ const args = this.parseCallableArguments(line, false, true);
704
+ const node = n(
705
+ NodeType.MacroReference,
706
+ { var: alias.node, name: n(NodeType.Constant, {}, { value: alias.name }, line), arguments: args },
707
+ { hasCallParentheses: true },
708
+ line,
709
+ );
710
+ return node;
711
+ }
712
+
713
+ const args = this.parseNamedArguments(false, `for function "${name}"`);
714
+ const callable = this.parser.getFunction(name, line);
715
+ if (callable.parameters) {
716
+ this.validateNamedArgs(args, callable.parameters, `for function "${callable.name}(${callable.parameters.join(', ')})"`, `for function "${callable.name}"`);
717
+ this.validateRequiredArgs(args, callable.parameters, callable.name, line);
718
+ }
719
+
720
+ if (name === 'parent') {
721
+ if (!this.parser.peekBlockStack()) {
722
+ throw new SyntaxError(
723
+ 'Calling the "parent" function outside of a block is forbidden.',
724
+ line,
725
+ this.getStream().getSourceContext(),
726
+ );
727
+ }
728
+ if (!this.parser.hasInheritance()) {
729
+ throw new SyntaxError(
730
+ 'Calling the "parent" function on a template that does not call "extends" or "use" is forbidden.',
731
+ line,
732
+ this.getStream().getSourceContext(),
733
+ );
734
+ }
735
+ }
736
+
737
+ return n(NodeType.FunctionCall, { arguments: args }, { name: callable.name }, line);
738
+ }
739
+
740
+ /**
741
+ * Validates that required arguments are present (e.g. `block(name)`).
742
+ *
743
+ * @param {Array<{name: string|null, value: Node}>} args
744
+ * @param {string[]} parameters
745
+ * @param {string} name
746
+ * @param {number} line
747
+ */
748
+ validateRequiredArgs(args, parameters, name, line) {
749
+ const required = REQUIRED_FUNCTION_ARGS[name];
750
+ if (!required) {
751
+ return;
752
+ }
753
+ const provided = new Set();
754
+ let positional = 0;
755
+ for (const arg of args) {
756
+ if (arg.name === null) {
757
+ while (positional < parameters.length && provided.has(parameters[positional])) {
758
+ positional += 1;
759
+ }
760
+ if (positional < parameters.length) {
761
+ provided.add(parameters[positional]);
762
+ positional += 1;
763
+ }
764
+ } else {
765
+ const normalized = arg.name.toLowerCase().replace(/_/g, '');
766
+ const index = parameters.map((p) => p.toLowerCase().replace(/_/g, '')).indexOf(normalized);
767
+ if (index >= 0) {
768
+ provided.add(parameters[index]);
769
+ }
770
+ }
771
+ }
772
+ for (const requiredParam of required) {
773
+ if (!provided.has(requiredParam)) {
774
+ throw new SyntaxError(
775
+ `Value for argument "${requiredParam}" is required for function "${name}".`,
776
+ line,
777
+ this.getStream().getSourceContext(),
778
+ );
779
+ }
780
+ }
781
+ }
782
+
783
+ /**
784
+ * Parses a test (`expression is name(...)`).
785
+ *
786
+ * @param {Node} expression The tested expression.
787
+ * @param {boolean} negate Whether to wrap in `not`.
788
+ * @returns {Node} A `test` node (possibly wrapped in `not`).
789
+ */
790
+ parseTest(expression, negate) {
791
+ const stream = this.getStream();
792
+ const line = stream.getCurrent().getLine();
793
+ const test = this.parser.getTest(line);
794
+
795
+ /** @type {Array<{name: string|null, value: Node}>|null} */
796
+ let args = null;
797
+ if (stream.test(TokenType.OPERATOR, '(')) {
798
+ args = this.parseNamedArguments(true, `for test "${test.name}"`);
799
+ } else if (test.oneMandatoryArgument) {
800
+ args = [{ name: null, value: this.parseExpression(100) }];
801
+ }
802
+ if (args && test.parameters) {
803
+ this.validateNamedArgs(args, test.parameters, `for test "${test.name}"`);
804
+ }
805
+
806
+ let node = n(NodeType.Test, { node: expression, arguments: args }, { name: test.name }, line);
807
+
808
+ // `x is defined` where x is a from-imported macro resolves the macro
809
+ if (test.name === 'defined' && expression.type === NodeType.ContextVariable) {
810
+ const alias = this.parser.getImportedSymbol('function', String(expression.getAttribute('name')));
811
+ if (alias) {
812
+ const macroReference = n(
813
+ NodeType.MacroReference,
814
+ { var: alias.node, name: n(NodeType.Constant, {}, { value: alias.name }, line), arguments: this.makeArray(line) },
815
+ { hasCallParentheses: false },
816
+ line,
817
+ );
818
+ node = n(NodeType.Test, { node: macroReference, arguments: args }, { name: test.name }, line);
819
+ }
820
+ }
821
+
822
+ if (negate) {
823
+ node = n(NodeType.Unary, { node }, { operator: 'not' }, line);
824
+ }
825
+ return node;
826
+ }
827
+
828
+ /**
829
+ * Parses a binary operator expression.
830
+ *
831
+ * @param {string} operator The operator.
832
+ * @param {Node} left
833
+ * @param {{precedence: number, right: boolean}} operatorInfo Operator metadata.
834
+ * @returns {Node} A `binary` node.
835
+ */
836
+ parseBinary(operator, left, operatorInfo) {
837
+ const right = this.parseExpression(operatorInfo.right ? operatorInfo.precedence : operatorInfo.precedence + 1);
838
+ return n(NodeType.Binary, { left, right }, { operator: operator }, left.getTemplateLine());
839
+ }
840
+
841
+ /**
842
+ * Parses the conditional operator (`a ? b : c`).
843
+ *
844
+ * @param {Node} left
845
+ * @returns {Node} A `conditional` node.
846
+ */
847
+ parseConditional(left) {
848
+ const stream = this.getStream();
849
+ const then = this.parseExpression(0);
850
+ let els;
851
+ if (stream.nextIf(TokenType.PUNCTUATION, ':')) {
852
+ els = this.parseExpression(0);
853
+ } else {
854
+ els = n(NodeType.Constant, {}, { value: '' }, left.getTemplateLine());
855
+ }
856
+ return n(NodeType.Conditional, { cond: left, then, else: els }, {}, left.getTemplateLine());
857
+ }
858
+
859
+ /**
860
+ * Parses an assignment (`target = expression`) including destructuring.
861
+ *
862
+ * @param {Node} left The assignment target.
863
+ * @returns {Node} A `set`, `sequence_destructuring_set` or `object_destructuring_set` node.
864
+ */
865
+ parseAssignment(left) {
866
+ const stream = this.getStream();
867
+ const line = left.getTemplateLine();
868
+ if (left.type !== NodeType.ContextVariable && !(left instanceof ArrayExpression)) {
869
+ throw new SyntaxError(
870
+ `Cannot assign to "${left.type}", only variables can be assigned.`,
871
+ line,
872
+ stream.getSourceContext(),
873
+ );
874
+ }
875
+ const right = this.parseExpression(0);
876
+
877
+ if (left instanceof ArrayExpression) {
878
+ /** @type {Array<[Node, Node]>} */
879
+ const pairs = left.getKeyValuePairs();
880
+ const isSequence = left.isSequence();
881
+
882
+ const hasVar = pairs.some(([, value]) => value.type !== NodeType.Empty);
883
+ if (!pairs.length || (isSequence && !hasVar)) {
884
+ throw new SyntaxError(
885
+ 'Cannot destructure to an empty list of variables.',
886
+ line,
887
+ stream.getSourceContext(),
888
+ );
889
+ }
890
+
891
+ // convert plain ContextVariable elements to assignable variables
892
+ for (const pair of pairs) {
893
+ if (pair[1] && pair[1].type === NodeType.ContextVariable) {
894
+ const v = pair[1];
895
+ pair[1] = n(NodeType.AssignContextVariable, {}, { name: v.getAttribute('name') }, v.getTemplateLine());
896
+ }
897
+ }
898
+
899
+ if (isSequence) {
900
+ return n(
901
+ NodeType.SequenceDestructuringSet,
902
+ { left, right },
903
+ {},
904
+ line,
905
+ );
906
+ }
907
+ return n(NodeType.ObjectDestructuringSet, { left, right }, {}, line);
908
+ }
909
+
910
+ return n(NodeType.SetBinary, { left, right }, {}, line);
911
+ }
912
+
913
+ /**
914
+ * Parses an arrow function (`x => expression`).
915
+ *
916
+ * @param {Node} left The parameter(s).
917
+ * @returns {Node} An `arrow_function` node.
918
+ */
919
+ parseArrow(left) {
920
+ const token = this.current();
921
+ if (
922
+ left.type !== NodeType.ContextVariable &&
923
+ left.type !== NodeType.ListExpr &&
924
+ left.type !== NodeType.AssignContextVariable
925
+ ) {
926
+ throw new SyntaxError(
927
+ 'The arrow function argument must be a list of variables or a single variable.',
928
+ token.getLine(),
929
+ this.getStream().getSourceContext(),
930
+ );
931
+ }
932
+ const body = this.parseExpression(0);
933
+ return n(NodeType.ArrowFunction, { body, arguments: left }, {}, token.getLine());
934
+ }
935
+
936
+ /**
937
+ * Parses a named/positional argument list (already positioned after `(`).
938
+ *
939
+ * @param {boolean} parseOpenParenthesis Whether to expect and consume `(`.
940
+ * @param {string} [context] Callable context appended to errors (e.g. `for function "date"`).
941
+ * @returns {Array<{name: string|null, value: Node}>} Parsed arguments.
942
+ */
943
+ parseNamedArguments(parseOpenParenthesis = true, context = '') {
944
+ const stream = this.getStream();
945
+ if (parseOpenParenthesis) {
946
+ stream.expect(TokenType.OPERATOR, '(', 'A list of arguments must begin with an opening parenthesis');
947
+ }
948
+ /** @type {Array<{name: string|null, value: Node}>} */
949
+ const args = [];
950
+ let hasSpread = false;
951
+ let sawNamed = false;
952
+ while (!stream.test(TokenType.PUNCTUATION, ')')) {
953
+ if (args.length) {
954
+ stream.expect(TokenType.PUNCTUATION, ',', 'Arguments must be separated by a comma');
955
+ // trailing comma
956
+ if (stream.test(TokenType.PUNCTUATION, ')')) {
957
+ break;
958
+ }
959
+ }
960
+
961
+ let value = this.parseExpression();
962
+ if (value.type === NodeType.Unary && value.getAttribute('operator') === '...') {
963
+ hasSpread = true;
964
+ } else if (hasSpread) {
965
+ throw new SyntaxError(
966
+ 'Normal arguments must be placed before argument unpacking.',
967
+ stream.getCurrent().getLine(),
968
+ stream.getSourceContext(),
969
+ );
970
+ }
971
+
972
+ let name = null;
973
+ if (value.type === NodeType.SetBinary) {
974
+ const left = /** @type {Node} */ (value.getNode('left'));
975
+ name = String(left.getAttribute('name'));
976
+ value = /** @type {Node} */ (value.getNode('right'));
977
+ } else if (
978
+ stream.nextIf(TokenType.OPERATOR, '=') ||
979
+ stream.nextIf(TokenType.PUNCTUATION, ':')
980
+ ) {
981
+ if (value.type !== NodeType.ContextVariable) {
982
+ throw new SyntaxError(
983
+ `A parameter name must be a string, "${value.type}" given.`,
984
+ stream.getCurrent().getLine(),
985
+ stream.getSourceContext(),
986
+ );
987
+ }
988
+ name = String(value.getAttribute('name'));
989
+ value = this.parseExpression();
990
+ }
991
+
992
+ if (name === null) {
993
+ if (sawNamed) {
994
+ throw new SyntaxError(
995
+ `Positional arguments cannot be used after named arguments${context ? ` ${context}` : ''}.`,
996
+ stream.getCurrent().getLine(),
997
+ stream.getSourceContext(),
998
+ );
999
+ }
1000
+ args.push({ name: null, value });
1001
+ } else {
1002
+ if (args.some((a) => a.name === name)) {
1003
+ throw new SyntaxError(
1004
+ `Argument "${name}" is defined twice${context ? ` ${context}` : ''}.`,
1005
+ stream.getCurrent().getLine(),
1006
+ stream.getSourceContext(),
1007
+ );
1008
+ }
1009
+ sawNamed = true;
1010
+ args.push({ name, value });
1011
+ }
1012
+ }
1013
+ stream.expect(TokenType.PUNCTUATION, ')', 'A list of arguments must be closed by a parenthesis');
1014
+ return args;
1015
+ }
1016
+
1017
+ /**
1018
+ * Validates named arguments against a callable's parameter names.
1019
+ *
1020
+ * @param {Array<{name: string|null, value: Node}>} args
1021
+ * @param {string[]} parameters
1022
+ * @param {string} context e.g. `function "include(template, variables)"`.
1023
+ * @param {string} [definedContext] Context for the "defined twice" error
1024
+ * (e.g. `function "date"` without the signature).
1025
+ */
1026
+ validateNamedArgs(args, parameters, context, definedContext) {
1027
+ const normalize = (/** @type {string} */ p) => p.toLowerCase().replace(/_/g, '');
1028
+ const normalizedParameters = parameters.map(normalize);
1029
+ const twiceContext = definedContext ?? context;
1030
+ /** @type {Set<string>} */
1031
+ const used = new Set();
1032
+ let positionalIndex = 0;
1033
+ for (const arg of args) {
1034
+ if (arg.name === null) {
1035
+ while (positionalIndex < parameters.length && used.has(parameters[positionalIndex])) {
1036
+ positionalIndex += 1;
1037
+ }
1038
+ if (positionalIndex < parameters.length) {
1039
+ const parameter = parameters[positionalIndex];
1040
+ if (used.has(parameter)) {
1041
+ throw new SyntaxError(
1042
+ `Argument "${arg.name ?? ''}" is defined twice ${twiceContext}.`,
1043
+ arg.value.getTemplateLine(),
1044
+ this.getStream().getSourceContext(),
1045
+ );
1046
+ }
1047
+ used.add(parameter);
1048
+ positionalIndex += 1;
1049
+ }
1050
+ continue;
1051
+ }
1052
+ const normalized = normalize(arg.name);
1053
+ const index = normalizedParameters.indexOf(normalized);
1054
+ if (index === -1) {
1055
+ throw new SyntaxError(
1056
+ `Unknown argument "${arg.name}" ${context}.`,
1057
+ arg.value.getTemplateLine(),
1058
+ this.getStream().getSourceContext(),
1059
+ );
1060
+ }
1061
+ if (used.has(parameters[index])) {
1062
+ throw new SyntaxError(
1063
+ `Argument "${arg.name}" is defined twice ${twiceContext}.`,
1064
+ arg.value.getTemplateLine(),
1065
+ this.getStream().getSourceContext(),
1066
+ );
1067
+ }
1068
+ used.add(parameters[index]);
1069
+ }
1070
+ }
1071
+
1072
+ /**
1073
+ * Parses callable arguments into an array node (for method calls / macros).
1074
+ *
1075
+ * @param {number} line
1076
+ * @param {boolean} [parseOpenParenthesis]
1077
+ * @param {boolean} [preserveNames]
1078
+ * @returns {ArrayExpression} An `array` node.
1079
+ */
1080
+ parseCallableArguments(line, parseOpenParenthesis = true, preserveNames = false) {
1081
+ const node = this.makeArray(line);
1082
+ const args = this.parseNamedArguments(parseOpenParenthesis);
1083
+ for (let i = 0; i < args.length; i++) {
1084
+ const arg = args[i];
1085
+ const keyNode =
1086
+ arg.name === null || !preserveNames
1087
+ ? n(NodeType.LocalVariable, {}, { name: i }, line)
1088
+ : n(NodeType.Constant, {}, { value: arg.name }, line);
1089
+ node.addElement(arg.value, keyNode);
1090
+ }
1091
+ return node;
1092
+ }
1093
+
1094
+ /**
1095
+ * Creates an array expression node.
1096
+ *
1097
+ * @param {number} line
1098
+ * @returns {import('./array-expression.js').ArrayExpression} The array node.
1099
+ */
1100
+ makeArray(line) {
1101
+ return new ArrayExpression(line);
1102
+ }
1103
+ }