@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.
package/src/parser.js ADDED
@@ -0,0 +1,1776 @@
1
+ // @ts-check
2
+ /**
3
+ * The Twig parser: builds an AST from a token stream.
4
+ *
5
+ * Mirrors `src/Parser.php` plus the core token parsers from
6
+ * `src/TokenParser/*` of the reference implementation. See
7
+ * `spec/04-tags.md`.
8
+ *
9
+ * @module twig-parser
10
+ */
11
+ import { TokenType, SyntaxError, Token } from '@mrhenry/twig-tokenizer';
12
+ import { Node, NodeType, n, captureSignatures } from './node.js';
13
+ import { ExpressionParser } from './expression-parser.js';
14
+ import {
15
+ CORE_FILTER_NAMES,
16
+ CORE_FUNCTION_NAMES,
17
+ CORE_TEST_NAMES,
18
+ } from './callables.js';
19
+
20
+ /**
21
+ * @typedef {import('@mrhenry/twig-tokenizer').TokenStream} TokenStream
22
+ * @typedef {import('@mrhenry/twig-tokenizer').Source} Source
23
+ */
24
+
25
+ /**
26
+ * A callable descriptor resolved from the environment.
27
+ *
28
+ * @typedef {object} CallableDescriptor
29
+ * @property {string} name
30
+ * @property {boolean} [oneMandatoryArgument]
31
+ * @property {string[]} [parameters]
32
+ */
33
+
34
+ /**
35
+ * The environment contract the parser requires.
36
+ *
37
+ * @typedef {object} ParserEnvironment
38
+ * @property {(name: string, line: number) => CallableDescriptor|null} getFunction
39
+ * @property {(name: string, line: number) => CallableDescriptor|null} getFilter
40
+ * @property {(name: string) => CallableDescriptor|null} getTest
41
+ */
42
+
43
+ /** Name pattern for word-operators used as variable names. */
44
+ const REGULAR_EXPRESSION_NAME = /^[a-zA-Z_\u007f-\uffff][a-zA-Z0-9_\u007f-\uffff]*$/;
45
+
46
+ /** Reserved literal words that cannot be assigned to. */
47
+ const RESERVED_WORDS = new Set(['true', 'TRUE', 'false', 'FALSE', 'none', 'NONE', 'null', 'NULL']);
48
+
49
+ /**
50
+ * An imported macro symbol.
51
+ *
52
+ * @typedef {object} ImportedSymbol
53
+ * @property {string|null} name The macro name in the source template.
54
+ * @property {Node|null} node The internal macro variable node.
55
+ */
56
+
57
+ /**
58
+ * The parser state saved across nested (embed) parses.
59
+ *
60
+ * @typedef {object} ParserSavedState
61
+ * @property {TokenStream|null} stream
62
+ * @property {Node|null} parent
63
+ * @property {Record<string, Node>} blocks
64
+ * @property {Record<string, Node>} macros
65
+ * @property {Node[]} traits
66
+ * @property {Array<Record<string, Record<string, ImportedSymbol>>>} importedSymbols
67
+ * @property {string[]} blockStack
68
+ * @property {boolean} hasExtends
69
+ * @property {number} blockDepth
70
+ * @property {number} macroDepth
71
+ * @property {number} capturingNodeDepth
72
+ * @property {Array<Node|null>} tagStack
73
+ * @property {string|null} currentTag
74
+ * @property {Node[]} embeddedTemplates
75
+ * @property {number} lastEmbedIndex
76
+ */
77
+
78
+ /**
79
+ * The template parser.
80
+ */
81
+ export class Parser {
82
+ /**
83
+ * @param {ParserEnvironment} environment The parser environment.
84
+ */
85
+ constructor(environment) {
86
+ /** @type {ParserEnvironment} */
87
+ this.environment = environment;
88
+ /** @type {ExpressionParser} */
89
+ this.expressionParser = new ExpressionParser(this);
90
+ /** @type {TokenStream|null} */
91
+ this.stream = null;
92
+ /** @type {Node|null} */
93
+ this.parent = null;
94
+ /** @type {Record<string, Node>} */
95
+ this.blocks = {};
96
+ /** @type {Node[]} */
97
+ this.blockNodes = [];
98
+ /** @type {string[]} */
99
+ this.blockStack = [];
100
+ /** @type {Record<string, Node>} */
101
+ this.macros = {};
102
+ /** @type {Node[]} */
103
+ this.traits = [];
104
+ /** @type {Array<Record<string, Record<string, ImportedSymbol>>>} */
105
+ this.importedSymbols = [{}];
106
+ /** @type {Node[]} */
107
+ this.embeddedTemplates = [];
108
+ /** @type {number} */
109
+ this.lastEmbedIndex = 0;
110
+ /** @type {boolean} */
111
+ this.ignoreUnknownTwigCallables = false;
112
+ /** @type {boolean} */
113
+ this.hasExtends = false;
114
+ /** @type {number} */
115
+ this.blockDepth = 0;
116
+ /** @type {number} */
117
+ this.macroDepth = 0;
118
+ /** @type {number} */
119
+ this.capturingNodeDepth = 0;
120
+ /** @type {Array<Node|null>} */
121
+ this.tagStack = [];
122
+ /** @type {string|null} */
123
+ this.currentTag = null;
124
+ /** @type {ParserSavedState[]} */
125
+ this.saveStack = [];
126
+ }
127
+
128
+ /**
129
+ * @returns {TokenStream} The token stream.
130
+ */
131
+ getStream() {
132
+ return /** @type {TokenStream} */ (this.stream);
133
+ }
134
+
135
+ /**
136
+ * @returns {Token} The current token.
137
+ */
138
+ getCurrentToken() {
139
+ return this.getStream().getCurrent();
140
+ }
141
+
142
+ /**
143
+ * @returns {ParserEnvironment} The environment.
144
+ */
145
+ getEnvironment() {
146
+ return this.environment;
147
+ }
148
+
149
+ /**
150
+ * Parses a full template into a module node.
151
+ *
152
+ * @param {TokenStream} stream
153
+ * @param {((token: Token) => boolean)|null} [test]
154
+ * @param {boolean} [dropNeedle]
155
+ * @returns {Node} The module node.
156
+ */
157
+ parse(stream, test = null, dropNeedle = false) {
158
+ if (!this.stream) {
159
+ this.lastEmbedIndex = 0;
160
+ }
161
+
162
+ // save the current state so nested (embed) parses can restore it
163
+ const saved = {
164
+ stream: this.stream,
165
+ parent: this.parent,
166
+ blocks: this.blocks,
167
+ macros: this.macros,
168
+ traits: this.traits,
169
+ importedSymbols: this.importedSymbols,
170
+ blockStack: this.blockStack,
171
+ hasExtends: this.hasExtends,
172
+ blockDepth: this.blockDepth,
173
+ macroDepth: this.macroDepth,
174
+ capturingNodeDepth: this.capturingNodeDepth,
175
+ tagStack: this.tagStack,
176
+ currentTag: this.currentTag,
177
+ embeddedTemplates: this.embeddedTemplates,
178
+ lastEmbedIndex: this.lastEmbedIndex,
179
+ };
180
+ this.saveStack.push(saved);
181
+
182
+ this.stream = stream;
183
+ this.parent = null;
184
+ this.blocks = {};
185
+ this.blockStack = [];
186
+ this.macros = {};
187
+ this.traits = [];
188
+ this.importedSymbols = [{}];
189
+ this.embeddedTemplates = [];
190
+ this.hasExtends = false;
191
+ this.blockDepth = 0;
192
+ this.macroDepth = 0;
193
+ this.capturingNodeDepth = 0;
194
+ this.tagStack = [];
195
+
196
+ let body;
197
+ try {
198
+ body = this.subparse(test, dropNeedle);
199
+ } catch (e) {
200
+ if (e instanceof SyntaxError) {
201
+ if (!e.getSourceContext()) {
202
+ e.setSourceContext(stream.getSourceContext());
203
+ }
204
+ if (e.getTemplateLine() < 0) {
205
+ e.setTemplateLine(this.getCurrentToken().getLine());
206
+ }
207
+ }
208
+ throw e;
209
+ }
210
+
211
+ if (this.parent) {
212
+ body = this.cleanupBodyForChildTemplates(body);
213
+ }
214
+
215
+ const module = n(
216
+ NodeType.Module,
217
+ {
218
+ body: n(NodeType.Body, { nodes: [body] }, {}, body.getTemplateLine()),
219
+ parent: this.parent,
220
+ blocks: Object.keys(this.blocks).length ? this.blocks : null,
221
+ macros: this.macros,
222
+ traits: this.traits.length ? this.traits : null,
223
+ embeddedTemplates: this.embeddedTemplates.length ? this.embeddedTemplates : null,
224
+ },
225
+ {},
226
+ stream.getCurrent().getLine(),
227
+ 'module',
228
+ );
229
+ module.setSourceContext(stream.getSourceContext());
230
+ module.setAttribute('name', stream.getSourceContext()?.getName() ?? 'index.twig');
231
+ if (this.parent) {
232
+ module.setAttribute('parentLine', /** @type {Node} */ (this.parent).getTemplateLine());
233
+ }
234
+
235
+ const source = stream.getSourceContext();
236
+ if (source) {
237
+ module.leading = '';
238
+ module.rawStart = 0;
239
+ module.rawEnd = source.getCode().length;
240
+ module.raw = source.getCode();
241
+ }
242
+ module.setAttribute('trailing', stream.getCurrent().leading ?? '');
243
+
244
+ this.correctnessCheck(module);
245
+ captureSignatures(module);
246
+
247
+ // restore the previous state so an enclosing (outer) parse can resume
248
+ const previous = this.saveStack.pop();
249
+ if (previous) {
250
+ this.stream = previous.stream;
251
+ this.parent = previous.parent;
252
+ this.blocks = previous.blocks;
253
+ this.macros = previous.macros;
254
+ this.traits = previous.traits;
255
+ this.importedSymbols = previous.importedSymbols;
256
+ this.blockStack = previous.blockStack;
257
+ this.hasExtends = previous.hasExtends;
258
+ this.blockDepth = previous.blockDepth;
259
+ this.macroDepth = previous.macroDepth;
260
+ this.capturingNodeDepth = previous.capturingNodeDepth;
261
+ this.tagStack = previous.tagStack;
262
+ this.currentTag = previous.currentTag;
263
+ this.embeddedTemplates = previous.embeddedTemplates;
264
+ this.lastEmbedIndex = previous.lastEmbedIndex;
265
+ }
266
+
267
+ return module;
268
+ }
269
+
270
+ /**
271
+ * Parses template content until the given test returns true.
272
+ *
273
+ * @param {((token: Token) => boolean)|null} [test]
274
+ * @param {boolean} [dropNeedle]
275
+ * @returns {Node} The parsed body (single node or a `nodes` list).
276
+ */
277
+ subparse(test = null, dropNeedle = false) {
278
+ const stream = this.getStream();
279
+ const lineNumber = this.getCurrentToken().getLine();
280
+ /** @type {Node[]} */
281
+ const rv = [];
282
+ while (!stream.isEOF()) {
283
+ const current = stream.getCurrent();
284
+ const spanStart = current.rawStart;
285
+ const spanLeading = current.leading;
286
+ if (current.test(TokenType.TEXT)) {
287
+ const token = stream.next();
288
+ const node = n(NodeType.Text, {}, { data: token.value }, token.getLine(), 'text');
289
+ this.attachSpan(node, spanLeading, spanStart);
290
+ rv.push(node);
291
+ } else if (current.test(TokenType.VAR_START)) {
292
+ const token = stream.next();
293
+ const expression = this.parseExpression();
294
+ stream.expect(TokenType.VAR_END);
295
+ const node = n(NodeType.Print, { expr: expression }, {}, token.getLine(), 'print');
296
+ this.attachSpan(node, spanLeading, spanStart);
297
+ rv.push(node);
298
+ } else if (current.test(TokenType.BLOCK_START)) {
299
+ stream.next();
300
+ const token = this.getCurrentToken();
301
+ if (!token.test(TokenType.NAME)) {
302
+ throw new SyntaxError(
303
+ 'A block must start with a tag name.',
304
+ token.getLine(),
305
+ stream.getSourceContext(),
306
+ );
307
+ }
308
+ if (test && test(token)) {
309
+ if (dropNeedle) {
310
+ stream.next();
311
+ }
312
+ if (rv.length === 1) {
313
+ return rv[0];
314
+ }
315
+ const nodes = n(NodeType.Nodes, { nodes: rv }, {}, lineNumber);
316
+ this.attachContainerSpan(nodes, rv);
317
+ return nodes;
318
+ }
319
+
320
+ const tag = token.value;
321
+ const subparser = this.getTokenParser(tag);
322
+ if (!subparser) {
323
+ if (test) {
324
+ throw new SyntaxError(
325
+ `Unexpected "${tag}" tag (expecting closing tag for the "${this.currentTag ?? tag}" tag defined near line ${lineNumber}).`,
326
+ token.getLine(),
327
+ stream.getSourceContext(),
328
+ );
329
+ }
330
+ const e = new SyntaxError(`Unknown "${tag}" tag.`, token.getLine(), stream.getSourceContext());
331
+ e.addSuggestions(tag, Object.keys(TAG_PARSERS));
332
+ throw e;
333
+ }
334
+
335
+ stream.next();
336
+ this.tagStack.push(null);
337
+ const previousTag = this.currentTag;
338
+ this.currentTag = tag;
339
+ const node = subparser.parse(token);
340
+ this.currentTag = previousTag;
341
+ this.tagStack.pop();
342
+ node.setNodeTag(tag);
343
+ this.attachSpan(node, spanLeading, spanStart);
344
+ rv.push(node);
345
+ } else {
346
+ throw new SyntaxError(
347
+ 'The lexer or the parser ended up in an unsupported state.',
348
+ this.getCurrentToken().getLine(),
349
+ stream.getSourceContext(),
350
+ );
351
+ }
352
+ }
353
+
354
+ if (rv.length === 1) {
355
+ return rv[0];
356
+ }
357
+ const nodes = n(NodeType.Nodes, { nodes: rv }, {}, lineNumber);
358
+ this.attachContainerSpan(nodes, rv);
359
+ return nodes;
360
+ }
361
+
362
+ /**
363
+ * Records the source span covered by a parsed node.
364
+ *
365
+ * @param {Node} node
366
+ * @param {string} leading Trivia preceding the span.
367
+ * @param {number} start Absolute start offset.
368
+ */
369
+ attachSpan(node, leading, start) {
370
+ const source = this.getStream().getSourceContext();
371
+ if (!source) {
372
+ return;
373
+ }
374
+ const end = this.getCurrentToken().sourceStart;
375
+ node.leading = leading;
376
+ node.rawStart = start;
377
+ node.rawEnd = end;
378
+ node.raw = start <= end ? source.getCode().slice(start, end) : '';
379
+ }
380
+
381
+ /**
382
+ * Records the source span covering a list of already-spanned children.
383
+ *
384
+ * @param {Node} node The container node (a `nodes` node).
385
+ * @param {Node[]} children The spanned children.
386
+ */
387
+ attachContainerSpan(node, children) {
388
+ const source = this.getStream().getSourceContext();
389
+ if (!source || children.length === 0) {
390
+ return;
391
+ }
392
+ const first = children[0];
393
+ const last = children[children.length - 1];
394
+ node.leading = first.leading;
395
+ node.rawStart = first.rawStart;
396
+ node.rawEnd = last.rawEnd;
397
+ node.raw = source.getCode().slice(first.rawStart, last.rawEnd);
398
+ }
399
+
400
+ /**
401
+ * Runs `subparse` while ignoring unknown Twig callables (used by `guard`).
402
+ *
403
+ * @param {((token: Token) => boolean)|null} [test]
404
+ * @param {boolean} [dropNeedle]
405
+ * @returns {Node} The parsed body.
406
+ */
407
+ subparseIgnoreUnknownTwigCallables(test = null, dropNeedle = false) {
408
+ const previous = this.ignoreUnknownTwigCallables;
409
+ this.ignoreUnknownTwigCallables = true;
410
+ try {
411
+ return this.subparse(test, dropNeedle);
412
+ } finally {
413
+ this.ignoreUnknownTwigCallables = previous;
414
+ }
415
+ }
416
+
417
+ /**
418
+ * @returns {boolean} Whether unknown callables should be ignored.
419
+ */
420
+ shouldIgnoreUnknownTwigCallables() {
421
+ return this.ignoreUnknownTwigCallables;
422
+ }
423
+
424
+ /**
425
+ * Parses an expression.
426
+ *
427
+ * @param {number} [precedence]
428
+ * @returns {Node} The expression node.
429
+ */
430
+ parseExpression(precedence = 0) {
431
+ return this.expressionParser.parseExpression(precedence);
432
+ }
433
+
434
+ /**
435
+ * Parses an assignment target list (`a, b`).
436
+ *
437
+ * @returns {Node[]} The target nodes.
438
+ */
439
+ parseAssignmentExpression() {
440
+ const stream = this.getStream();
441
+ /** @type {Node[]} */
442
+ const targets = [];
443
+ for (;;) {
444
+ const token = stream.getCurrent();
445
+ if (stream.test(TokenType.OPERATOR) && REGULAR_EXPRESSION_NAME.test(String(token.value))) {
446
+ stream.next();
447
+ } else {
448
+ stream.expect(TokenType.NAME, null, 'Only variables can be assigned to');
449
+ }
450
+ const name = String(token.value);
451
+ if (['true', 'TRUE', 'false', 'FALSE', 'none', 'NONE', 'null', 'NULL'].includes(name)) {
452
+ throw new SyntaxError(
453
+ `You cannot assign a value to "${name}".`,
454
+ token.getLine(),
455
+ stream.getSourceContext(),
456
+ );
457
+ }
458
+ targets.push(
459
+ n(NodeType.AssignContextVariable, {}, { name }, token.getLine()),
460
+ );
461
+ if (!stream.nextIf(TokenType.PUNCTUATION, ',')) {
462
+ break;
463
+ }
464
+ }
465
+ return targets;
466
+ }
467
+
468
+ /**
469
+ * Parses a multi-target expression list (`1, 2, 3`).
470
+ *
471
+ * @returns {Node[]} The parsed expressions.
472
+ */
473
+ parseMultitargetExpression() {
474
+ const stream = this.getStream();
475
+ /** @type {Node[]} */
476
+ const targets = [];
477
+ for (;;) {
478
+ targets.push(this.parseExpression());
479
+ if (!stream.nextIf(TokenType.PUNCTUATION, ',')) {
480
+ break;
481
+ }
482
+ }
483
+ return targets;
484
+ }
485
+
486
+ /**
487
+ * Resolves a function descriptor.
488
+ *
489
+ * @param {string} name
490
+ * @param {number} line
491
+ * @returns {CallableDescriptor}
492
+ */
493
+ getFunction(name, line) {
494
+ let callable = null;
495
+ try {
496
+ callable = this.environment.getFunction(name, line);
497
+ } catch (e) {
498
+ if (!this.shouldIgnoreUnknownTwigCallables()) {
499
+ throw e;
500
+ }
501
+ }
502
+ if (!callable) {
503
+ if (this.shouldIgnoreUnknownTwigCallables()) {
504
+ return { name };
505
+ }
506
+ const e = new SyntaxError(`Unknown "${name}" function.`, line, this.getStream().getSourceContext());
507
+ e.addSuggestions(name, CORE_FUNCTION_NAMES);
508
+ throw e;
509
+ }
510
+ return callable;
511
+ }
512
+
513
+ /**
514
+ * Resolves a filter descriptor.
515
+ *
516
+ * @param {string} name
517
+ * @param {number} line
518
+ * @returns {CallableDescriptor}
519
+ */
520
+ getFilter(name, line) {
521
+ let filter = null;
522
+ try {
523
+ filter = this.environment.getFilter(name, line);
524
+ } catch (e) {
525
+ if (!this.shouldIgnoreUnknownTwigCallables()) {
526
+ throw e;
527
+ }
528
+ }
529
+ if (!filter) {
530
+ if (this.shouldIgnoreUnknownTwigCallables()) {
531
+ return { name };
532
+ }
533
+ const e = new SyntaxError(`Unknown "${name}" filter.`, line, this.getStream().getSourceContext());
534
+ e.addSuggestions(name, CORE_FILTER_NAMES);
535
+ throw e;
536
+ }
537
+ return filter;
538
+ }
539
+
540
+ /**
541
+ * Resolves a test descriptor (consumes the test NAME token(s)).
542
+ *
543
+ * @param {number} line
544
+ * @returns {CallableDescriptor}
545
+ */
546
+ getTest(line) {
547
+ const stream = this.getStream();
548
+ const name = stream.expect(TokenType.NAME).getValue();
549
+ /** @type {CallableDescriptor|null} */
550
+ let test;
551
+ let name2 = null;
552
+
553
+ if (stream.test(TokenType.NAME)) {
554
+ name2 = `${name} ${this.getCurrentToken().getValue()}`;
555
+ try {
556
+ test = this.environment.getTest(name2);
557
+ } catch (e) {
558
+ if (!this.shouldIgnoreUnknownTwigCallables()) {
559
+ throw e;
560
+ }
561
+ test = null;
562
+ }
563
+ stream.next();
564
+ } else {
565
+ try {
566
+ test = this.environment.getTest(name);
567
+ } catch (e) {
568
+ if (!this.shouldIgnoreUnknownTwigCallables()) {
569
+ throw e;
570
+ }
571
+ test = null;
572
+ }
573
+ }
574
+
575
+ if (!test) {
576
+ if (this.shouldIgnoreUnknownTwigCallables()) {
577
+ return { name: name2 ?? name };
578
+ }
579
+ const e = new SyntaxError(
580
+ `Unknown "${name2 ?? name}" test.`,
581
+ line,
582
+ stream.getSourceContext(),
583
+ );
584
+ e.addSuggestions(name2 ?? name, CORE_TEST_NAMES);
585
+ throw e;
586
+ }
587
+ return test;
588
+ }
589
+
590
+ /**
591
+ * Registers a block definition.
592
+ *
593
+ * @param {string} name
594
+ * @param {Node} block
595
+ */
596
+ setBlock(name, block) {
597
+ if (this.blocks[name]) {
598
+ const existing = /** @type {Node} */ (this.blocks[name]);
599
+ throw new SyntaxError(
600
+ `The block '${name}' has already been defined line ${existing.getTemplateLine()}.`,
601
+ this.getCurrentToken().getLine(),
602
+ existing.getSourceContext(),
603
+ );
604
+ }
605
+ block.setSourceContext(this.getStream().getSourceContext());
606
+ this.blocks[name] = block;
607
+ }
608
+
609
+ /**
610
+ * Registers a macro.
611
+ *
612
+ * @param {string} name
613
+ * @param {Node} node
614
+ */
615
+ setMacro(name, node) {
616
+ this.macros[name] = node;
617
+ }
618
+
619
+ /**
620
+ * Adds a trait (from `use`).
621
+ *
622
+ * @param {Node} trait
623
+ */
624
+ addTrait(trait) {
625
+ this.traits.push(trait);
626
+ }
627
+
628
+ /**
629
+ * @returns {boolean} Whether the template has a parent or traits.
630
+ */
631
+ hasInheritance() {
632
+ return Boolean(this.parent) || this.traits.length > 0;
633
+ }
634
+
635
+ /**
636
+ * Sets the parent template expression.
637
+ *
638
+ * @param {Node} parent
639
+ * @param {boolean} [throwOnMultiple]
640
+ */
641
+ setParent(parent, throwOnMultiple = true) {
642
+ if (this.parent) {
643
+ if (!throwOnMultiple) {
644
+ return;
645
+ }
646
+ throw new SyntaxError(
647
+ 'Multiple extends tags are forbidden.',
648
+ parent.getTemplateLine(),
649
+ parent.getSourceContext(),
650
+ );
651
+ }
652
+ this.parent = parent;
653
+ }
654
+
655
+ /**
656
+ * Registers an imported macro symbol.
657
+ *
658
+ * @param {string} type
659
+ * @param {string} alias
660
+ * @param {string|null} [name]
661
+ * @param {Node|null} [internalReference]
662
+ */
663
+ addImportedSymbol(type, alias, name = null, internalReference = null) {
664
+ this.importedSymbols[0][type] = this.importedSymbols[0][type] ?? {};
665
+ this.importedSymbols[0][type][alias] = { name, node: internalReference };
666
+ }
667
+
668
+ /**
669
+ * Looks up an imported symbol in the current and main scope.
670
+ *
671
+ * @param {string} type
672
+ * @param {string} alias
673
+ * @returns {ImportedSymbol|null}
674
+ */
675
+ getImportedSymbol(type, alias) {
676
+ const scope = this.importedSymbols[0][type]?.[alias];
677
+ if (scope) {
678
+ return scope;
679
+ }
680
+ const main = this.importedSymbols[this.importedSymbols.length - 1][type]?.[alias];
681
+ return main ?? null;
682
+ }
683
+
684
+ /**
685
+ * @returns {boolean} Whether we are in the main scope.
686
+ */
687
+ isMainScope() {
688
+ return this.importedSymbols.length === 1;
689
+ }
690
+
691
+ /** Pushes a local (block/macro) scope. */
692
+ pushLocalScope() {
693
+ this.importedSymbols.unshift({});
694
+ }
695
+
696
+ /** Pops a local scope. */
697
+ popLocalScope() {
698
+ this.importedSymbols.shift();
699
+ }
700
+
701
+ /**
702
+ * @returns {string|null} The current block name.
703
+ */
704
+ peekBlockStack() {
705
+ return this.blockStack[this.blockStack.length - 1] ?? null;
706
+ }
707
+
708
+ /**
709
+ * @param {string} name
710
+ */
711
+ pushBlockStack(name) {
712
+ this.blockStack.push(name);
713
+ }
714
+
715
+ /** Pops the block stack. */
716
+ popBlockStack() {
717
+ this.blockStack.pop();
718
+ }
719
+
720
+ /**
721
+ * Registers an embedded template.
722
+ *
723
+ * @param {Node} template
724
+ */
725
+ embedTemplate(template) {
726
+ this.lastEmbedIndex += 1;
727
+ template.setAttribute('index', this.lastEmbedIndex);
728
+ template.setAttribute('embeddedName', `__embedded_${this.lastEmbedIndex}`);
729
+ this.embeddedTemplates.push(template);
730
+ }
731
+
732
+ /**
733
+ * Resolves the token parser for a tag name.
734
+ *
735
+ * @param {string} name
736
+ * @returns {{parse: (token: Token) => Node}|null}
737
+ */
738
+ getTokenParser(name) {
739
+ if (TAG_PARSERS[name]) {
740
+ return { parse: (token) => TAG_PARSERS[name](this, token) };
741
+ }
742
+ return null;
743
+ }
744
+
745
+ /**
746
+ * Removes block references / blank text from child template bodies.
747
+ *
748
+ * @param {Node} body
749
+ * @returns {Node}
750
+ */
751
+ cleanupBodyForChildTemplates(body) {
752
+ if (body.type === NodeType.BlockReference) {
753
+ return n(NodeType.Empty, {}, {}, body.getTemplateLine());
754
+ }
755
+ if (body.type === NodeType.Text && isBlankText(String(body.getAttribute('data')))) {
756
+ return n(NodeType.Empty, {}, {}, body.getTemplateLine());
757
+ }
758
+ if (body.type === NodeType.Nodes) {
759
+ const nodes = body.getNodes('nodes').filter((node) => {
760
+ if (node.type === NodeType.BlockReference) {
761
+ return false;
762
+ }
763
+ if (node.type === NodeType.Text && isBlankText(String(node.getAttribute('data')))) {
764
+ return false;
765
+ }
766
+ return true;
767
+ });
768
+ if (nodes.length === 1) {
769
+ return nodes[0];
770
+ }
771
+ if (nodes.length === 0) {
772
+ return n(NodeType.Empty, {}, {}, body.getTemplateLine());
773
+ }
774
+ return n(NodeType.Nodes, { nodes }, {}, body.getTemplateLine());
775
+ }
776
+ return body;
777
+ }
778
+
779
+ /**
780
+ * Runs the correctness checks equivalent to `CorrectnessNodeVisitor`.
781
+ *
782
+ * @param {Node} module
783
+ */
784
+ correctnessCheck(module) {
785
+ const sourceContext = module.getSourceContext();
786
+ const hasParent = module.hasNode('parent');
787
+ const body = /** @type {Node} */ (module.getNode('body'));
788
+ const root = body.getNodes('nodes')[0] ?? body;
789
+
790
+ // 1. a template that extends another one cannot include content outside blocks
791
+ if (hasParent) {
792
+ const roots = root.type === NodeType.Nodes ? root.getNodes('nodes') : [root];
793
+ for (const r of roots) {
794
+ if (!this.isEmptyOutputNode(r)) {
795
+ throw new SyntaxError(
796
+ 'A template that extends another one cannot include content outside Twig blocks. Did you forget to put the content inside a {% block %} tag?',
797
+ r.getTemplateLine(),
798
+ sourceContext,
799
+ );
800
+ }
801
+ }
802
+ }
803
+
804
+ // 2. extends / use / block nesting checks
805
+ this.blockDepth = 0;
806
+ this.macroDepth = 0;
807
+ this.capturingNodeDepth = 0;
808
+ this.tagStack = [];
809
+ this.hasExtends = false;
810
+ /** @param {Node} node */
811
+ const walk = (node) => {
812
+ if (node.type === NodeType.Block) {
813
+ this.blockDepth += 1;
814
+ } else if (node.type === NodeType.Macro) {
815
+ this.macroDepth += 1;
816
+ } else if (node.type === NodeType.Set && node.getAttribute('capture')) {
817
+ this.capturingNodeDepth += 1;
818
+ } else if (node.tag && node.tag !== 'module' && node.type !== NodeType.BlockReference && node.type !== NodeType.Macro && node.type !== NodeType.Text && node.type !== NodeType.Print) {
819
+ this.tagStack.push(node);
820
+ }
821
+
822
+ if (node.type === NodeType.Extends) {
823
+ if (this.blockDepth) {
824
+ throw new SyntaxError('Cannot use "extend" in a block.', node.getTemplateLine(), node.getSourceContext() ?? sourceContext);
825
+ }
826
+ if (this.macroDepth) {
827
+ throw new SyntaxError('Cannot use "extend" in a macro.', node.getTemplateLine(), node.getSourceContext() ?? sourceContext);
828
+ }
829
+ if (this.hasExtends) {
830
+ throw new SyntaxError('Multiple extends tags are forbidden.', node.getTemplateLine(), node.getSourceContext() ?? sourceContext);
831
+ }
832
+ this.hasExtends = true;
833
+ }
834
+
835
+ if (node.type === NodeType.BlockReference && hasParent && this.blockDepth === 0 && this.macroDepth === 0 && this.capturingNodeDepth === 0 && this.tagStack.length) {
836
+ const tag = /** @type {Node} */ (this.tagStack[this.tagStack.length - 1]);
837
+ throw new SyntaxError(
838
+ `A "block" tag cannot be under a "${tag.tag}" tag (line ${tag.getTemplateLine()}).`,
839
+ node.getTemplateLine(),
840
+ node.getSourceContext() ?? sourceContext,
841
+ );
842
+ }
843
+
844
+ // do not descend into embedded (separate) modules
845
+ for (const key of Object.keys(node.children)) {
846
+ if (key === 'embeddedTemplates') {
847
+ continue;
848
+ }
849
+ const child = node.children[key];
850
+ if (Array.isArray(child)) {
851
+ for (const c of child) {
852
+ if (c instanceof Node) {
853
+ walk(c);
854
+ }
855
+ }
856
+ } else if (child instanceof Node) {
857
+ walk(child);
858
+ } else if (child !== null && typeof child === 'object') {
859
+ // keyed records of nodes (e.g. module `blocks` / `macros`)
860
+ for (const value of Object.values(/** @type {Record<string, unknown>} */ (child))) {
861
+ if (value instanceof Node) {
862
+ walk(value);
863
+ }
864
+ }
865
+ }
866
+ }
867
+ };
868
+ walk(module);
869
+ }
870
+
871
+ /**
872
+ * Returns true if a node never outputs anything.
873
+ *
874
+ * @param {Node} node
875
+ * @returns {boolean}
876
+ */
877
+ isEmptyOutputNode(node) {
878
+ switch (node.type) {
879
+ case NodeType.Text:
880
+ return isBlankText(String(node.getAttribute('data')));
881
+ case NodeType.Set:
882
+ if (node.getAttribute('capture')) {
883
+ return true;
884
+ }
885
+ break;
886
+ case NodeType.Empty:
887
+ case NodeType.Extends:
888
+ case NodeType.Use:
889
+ case NodeType.BlockReference:
890
+ case NodeType.Macro:
891
+ case NodeType.Types:
892
+ return true;
893
+ case NodeType.Print:
894
+ return false;
895
+ default:
896
+ break;
897
+ }
898
+ for (const child of node) {
899
+ if (!this.isEmptyOutputNode(child)) {
900
+ return false;
901
+ }
902
+ }
903
+ return true;
904
+ }
905
+ }
906
+
907
+ /**
908
+ * Whether text is blank (whitespace only).
909
+ *
910
+ * @param {string} text
911
+ * @returns {boolean}
912
+ */
913
+ export function isBlankText(text) {
914
+ // eslint-disable-next-line no-control-regex -- \x0B and \0 are valid blank characters
915
+ return /^[ \t\n\r\0\x0B\f]*$/.test(text);
916
+ }
917
+
918
+ /* ------------------------------------------------------------------------ */
919
+ /* Core token parsers */
920
+ /* ------------------------------------------------------------------------ */
921
+
922
+ /**
923
+ * @typedef {(parser: Parser, token: Token) => Node} TagParser
924
+ */
925
+
926
+ /**
927
+ * Core tags whose body is closed by a matching `end<name>` tag.
928
+ *
929
+ * `set` is not listed: `{% set x %}` is a capture block only when it has no
930
+ * `=` (see {@link TAG_END_NAMES} and `spec/04-tags.md` §4.17).
931
+ *
932
+ * @type {string[]}
933
+ */
934
+ export const BLOCK_TAGS = [
935
+ 'apply',
936
+ 'autoescape',
937
+ 'block',
938
+ 'embed',
939
+ 'for',
940
+ 'guard',
941
+ 'if',
942
+ 'macro',
943
+ 'sandbox',
944
+ 'with',
945
+ ];
946
+
947
+ /**
948
+ * Core tags that occupy a single tag and have no body.
949
+ *
950
+ * @type {string[]}
951
+ */
952
+ export const INLINE_TAGS = [
953
+ 'deprecated',
954
+ 'do',
955
+ 'extends',
956
+ 'flush',
957
+ 'from',
958
+ 'import',
959
+ 'include',
960
+ 'types',
961
+ 'use',
962
+ ];
963
+
964
+ /**
965
+ * Opening tag → matching closing tag for the block forms.
966
+ *
967
+ * Includes `set`, whose capture form (`{% set x %}...{% endset %}`) is a block
968
+ * even though the expression form is single-line.
969
+ *
970
+ * @type {Record<string, string>}
971
+ */
972
+ export const TAG_END_NAMES = {
973
+ apply: 'endapply',
974
+ autoescape: 'endautoescape',
975
+ block: 'endblock',
976
+ embed: 'endembed',
977
+ for: 'endfor',
978
+ guard: 'endguard',
979
+ if: 'endif',
980
+ macro: 'endmacro',
981
+ sandbox: 'endsandbox',
982
+ set: 'endset',
983
+ with: 'endwith',
984
+ };
985
+
986
+ /**
987
+ * Tag parser registry.
988
+ *
989
+ * @type {Record<string, TagParser>}
990
+ */
991
+ export const TAG_PARSERS = {
992
+ /** `{% set name = expression %}` / `{% set name %}...{% endset %}` */
993
+ set(parser, token) {
994
+ const lineNumber = token.getLine();
995
+ const stream = parser.getStream();
996
+ const names = parser.parseAssignmentExpression();
997
+
998
+ let capture = false;
999
+ /** @type {Node[]} */
1000
+ let values;
1001
+ if (stream.nextIf(TokenType.OPERATOR, '=')) {
1002
+ values = parser.parseMultitargetExpression();
1003
+ stream.expect(TokenType.BLOCK_END);
1004
+ if (names.length !== values.length) {
1005
+ throw new SyntaxError(
1006
+ 'When using set, you must have the same number of variables and assignments.',
1007
+ stream.getCurrent().getLine(),
1008
+ stream.getSourceContext(),
1009
+ );
1010
+ }
1011
+ } else {
1012
+ capture = true;
1013
+ if (names.length > 1) {
1014
+ throw new SyntaxError(
1015
+ 'When using set with a block, you cannot have a multi-target.',
1016
+ stream.getCurrent().getLine(),
1017
+ stream.getSourceContext(),
1018
+ );
1019
+ }
1020
+ stream.expect(TokenType.BLOCK_END);
1021
+ values = [parser.subparse(decideTagEnd('endset'), true)];
1022
+ stream.expect(TokenType.BLOCK_END);
1023
+ }
1024
+
1025
+ return n(
1026
+ NodeType.Set,
1027
+ { names: n(NodeType.Nodes, { nodes: names }, {}, lineNumber), values: n(NodeType.Nodes, { nodes: values }, {}, lineNumber), body: capture ? values[0] : null },
1028
+ { capture },
1029
+ lineNumber,
1030
+ );
1031
+ },
1032
+
1033
+ /** `{% for target in expression %}...{% endfor %}` */
1034
+ for(parser, token) {
1035
+ const lineNumber = token.getLine();
1036
+ const stream = parser.getStream();
1037
+ const targets = parser.parseAssignmentExpression();
1038
+ stream.expect(TokenType.OPERATOR, 'in');
1039
+ const sequence = parser.parseExpression();
1040
+
1041
+ stream.expect(TokenType.BLOCK_END);
1042
+ const body = parser.subparse(decideForFork);
1043
+ let elseBody = null;
1044
+ if ('else' === stream.next().value) {
1045
+ stream.expect(TokenType.BLOCK_END);
1046
+ elseBody = parser.subparse(decideTagEnd('endfor'), true);
1047
+ }
1048
+ stream.expect(TokenType.BLOCK_END);
1049
+
1050
+ let keyTarget;
1051
+ let valueTarget;
1052
+ if (targets.length > 1) {
1053
+ keyTarget = targets[0];
1054
+ valueTarget = targets[1];
1055
+ } else {
1056
+ keyTarget = n(NodeType.AssignContextVariable, {}, { name: '_key' }, lineNumber);
1057
+ valueTarget = targets[0];
1058
+ }
1059
+
1060
+ return n(
1061
+ NodeType.For,
1062
+ { keyTarget, valueTarget, seq: sequence, body, elseBody },
1063
+ {},
1064
+ lineNumber,
1065
+ );
1066
+ },
1067
+
1068
+ /** `{% if cond %}...{% elseif %}...{% else %}...{% endif %}` */
1069
+ if(parser, token) {
1070
+ const lineNumber = token.getLine();
1071
+ const expression = parser.parseExpression();
1072
+ const stream = parser.getStream();
1073
+ stream.expect(TokenType.BLOCK_END);
1074
+ let body = parser.subparse(decideIfFork);
1075
+ /** Flat list: [cond, body, cond, body, ...] */
1076
+ const tests = [expression, body];
1077
+ let elseBody = null;
1078
+
1079
+ let end = false;
1080
+ while (!end) {
1081
+ const value = stream.next().value;
1082
+ switch (value) {
1083
+ case 'else':
1084
+ stream.expect(TokenType.BLOCK_END);
1085
+ elseBody = parser.subparse(decideTagEnd('endif'));
1086
+ break;
1087
+ case 'elseif':
1088
+ {
1089
+ const e = parser.parseExpression();
1090
+ stream.expect(TokenType.BLOCK_END);
1091
+ body = parser.subparse(decideIfFork);
1092
+ tests.push(e, body);
1093
+ }
1094
+ break;
1095
+ case 'endif':
1096
+ end = true;
1097
+ break;
1098
+ default:
1099
+ throw new SyntaxError(
1100
+ `Unexpected end of template. Twig was looking for the following tags "else", "elseif", or "endif" to close the "if" block started at line ${lineNumber}).`,
1101
+ stream.getCurrent().getLine(),
1102
+ stream.getSourceContext(),
1103
+ );
1104
+ }
1105
+ }
1106
+
1107
+ stream.expect(TokenType.BLOCK_END);
1108
+
1109
+ return n(NodeType.If, { tests, elseBody }, {}, lineNumber);
1110
+ },
1111
+
1112
+ /** `{% block name %}...{% endblock %}` */
1113
+ block(parser, token) {
1114
+ const lineNumber = token.getLine();
1115
+ const stream = parser.getStream();
1116
+ const name = stream.expect(TokenType.NAME).value;
1117
+ const block = n(NodeType.Block, { body: n(NodeType.Empty, {}, {}, lineNumber) }, { name }, lineNumber);
1118
+ parser.setBlock(name, block);
1119
+ parser.pushLocalScope();
1120
+ parser.pushBlockStack(name);
1121
+
1122
+ let body;
1123
+ if (stream.nextIf(TokenType.BLOCK_END)) {
1124
+ body = parser.subparse(decideTagEnd('endblock'), true);
1125
+ const nameToken = stream.nextIf(TokenType.NAME);
1126
+ if (nameToken) {
1127
+ const value = nameToken.value;
1128
+ if (value !== name) {
1129
+ throw new SyntaxError(
1130
+ `Expected endblock for block "${name}" (but "${value}" given).`,
1131
+ stream.getCurrent().getLine(),
1132
+ stream.getSourceContext(),
1133
+ );
1134
+ }
1135
+ }
1136
+ } else {
1137
+ body = n(NodeType.Nodes, { nodes: [n(NodeType.Print, { expr: parser.parseExpression() }, {}, lineNumber)] }, {}, lineNumber);
1138
+ }
1139
+ stream.expect(TokenType.BLOCK_END);
1140
+
1141
+ block.setNode('body', body);
1142
+ parser.popBlockStack();
1143
+ parser.popLocalScope();
1144
+
1145
+ const reference = n(NodeType.BlockReference, {}, { name }, lineNumber);
1146
+ reference.setAttribute('block', block);
1147
+ return reference;
1148
+ },
1149
+
1150
+ /** `{% macro name(args) %}...{% endmacro %}` */
1151
+ macro(parser, token) {
1152
+ const lineNumber = token.getLine();
1153
+ const stream = parser.getStream();
1154
+ const name = stream.expect(TokenType.NAME).value;
1155
+ const [argumentsList, variadicName] = parseMacroDefinition(parser, name);
1156
+
1157
+ stream.expect(TokenType.BLOCK_END);
1158
+ parser.pushLocalScope();
1159
+ const body = parser.subparse(decideTagEnd('endmacro'), true);
1160
+ const nameToken = stream.nextIf(TokenType.NAME);
1161
+ if (nameToken) {
1162
+ if (nameToken.value !== name) {
1163
+ throw new SyntaxError(
1164
+ `Expected endmacro for macro "${name}" (but "${nameToken.value}" given).`,
1165
+ stream.getCurrent().getLine(),
1166
+ stream.getSourceContext(),
1167
+ );
1168
+ }
1169
+ }
1170
+ parser.popLocalScope();
1171
+ stream.expect(TokenType.BLOCK_END);
1172
+
1173
+ const macro = n(
1174
+ NodeType.Macro,
1175
+ { body: n(NodeType.Body, { nodes: [body] }, {}, body.getTemplateLine()) },
1176
+ { name, arguments: argumentsList, variadicName },
1177
+ lineNumber,
1178
+ );
1179
+ macro.setSourceContext(stream.getSourceContext());
1180
+ parser.setMacro(name, macro);
1181
+
1182
+ const declaration = n(NodeType.MacroDeclaration, {}, { name }, lineNumber);
1183
+ declaration.setAttribute('macro', macro);
1184
+ return declaration;
1185
+ },
1186
+
1187
+ /** `{% include expression [ignore missing] [with expression] [only] %}` */
1188
+ include(parser, token) {
1189
+ const expression = parser.parseExpression();
1190
+ const [variables, only, ignoreMissing] = parseIncludeArguments(parser);
1191
+ return n(
1192
+ NodeType.Include,
1193
+ { expr: expression, variables, only: n(NodeType.Constant, {}, { value: only }, token.getLine()), ignoreMissing: n(NodeType.Constant, {}, { value: ignoreMissing }, token.getLine()) },
1194
+ {},
1195
+ token.getLine(),
1196
+ );
1197
+ },
1198
+
1199
+ /** `{% import expression as name %}` */
1200
+ import(parser, token) {
1201
+ const macro = parser.parseExpression();
1202
+ parser.getStream().expect(TokenType.NAME, 'as');
1203
+ const name = parser.getStream().expect(TokenType.NAME).value;
1204
+ const variableNode = n(
1205
+ NodeType.LocalVariable,
1206
+ {},
1207
+ { name, global: parser.isMainScope() },
1208
+ token.getLine(),
1209
+ );
1210
+ parser.getStream().expect(TokenType.BLOCK_END);
1211
+ parser.addImportedSymbol('template', name);
1212
+
1213
+ return n(NodeType.Import, { expr: macro, var: variableNode }, {}, token.getLine());
1214
+ },
1215
+
1216
+ /** `{% from expression import name [as alias], ... %}` */
1217
+ from(parser, token) {
1218
+ const macro = parser.parseExpression();
1219
+ const stream = parser.getStream();
1220
+ stream.expect(TokenType.NAME, 'import');
1221
+
1222
+ /** @type {Array<{name: string, alias: string}>} */
1223
+ const targets = [];
1224
+ for (;;) {
1225
+ const name = stream.expect(TokenType.NAME).value;
1226
+ let alias = name;
1227
+ if (stream.nextIf('as')) {
1228
+ alias = stream.expect(TokenType.NAME).value;
1229
+ }
1230
+ if (['true', 'TRUE', 'false', 'FALSE', 'none', 'NONE', 'null', 'NULL'].includes(alias)) {
1231
+ throw new SyntaxError(
1232
+ `You cannot assign a value to "${alias}".`,
1233
+ stream.getCurrent().getLine(),
1234
+ stream.getSourceContext(),
1235
+ );
1236
+ }
1237
+ targets.push({ name, alias });
1238
+
1239
+ if (!stream.nextIf(TokenType.PUNCTUATION, ',')) {
1240
+ break;
1241
+ }
1242
+ }
1243
+
1244
+ stream.expect(TokenType.BLOCK_END);
1245
+
1246
+ const internalReference = n(
1247
+ NodeType.LocalVariable,
1248
+ {},
1249
+ { name: null, global: parser.isMainScope() },
1250
+ token.getLine(),
1251
+ );
1252
+
1253
+ for (const target of targets) {
1254
+ parser.addImportedSymbol('function', target.alias, target.name, internalReference);
1255
+ }
1256
+
1257
+ return n(
1258
+ NodeType.Import,
1259
+ { expr: macro, var: internalReference, targets: n(NodeType.Nodes, { nodes: targets.map((t) => n(NodeType.Constant, {}, { value: { name: t.name, alias: t.alias } }, token.getLine())) }, {}, token.getLine()) },
1260
+ {},
1261
+ token.getLine(),
1262
+ );
1263
+ },
1264
+
1265
+ /** `{% extends expression %}` */
1266
+ extends(parser, token) {
1267
+ const stream = parser.getStream();
1268
+ const parent = parser.parseExpression();
1269
+ parser.setParent(parent, false);
1270
+ stream.expect(TokenType.BLOCK_END);
1271
+ const node = n(NodeType.Extends, {}, {}, token.getLine());
1272
+ node.setAttribute('parent', parent);
1273
+ return node;
1274
+ },
1275
+
1276
+ /** `{% use expression [with name [as alias], ...] %}` */
1277
+ use(parser, token) {
1278
+ const template = parser.parseExpression();
1279
+ const stream = parser.getStream();
1280
+ if (template.type !== NodeType.Constant) {
1281
+ throw new SyntaxError(
1282
+ 'The template references in a "use" statement must be a string.',
1283
+ stream.getCurrent().getLine(),
1284
+ stream.getSourceContext(),
1285
+ );
1286
+ }
1287
+
1288
+ /** @type {Array<[string, string]>} */
1289
+ const targets = [];
1290
+ if (stream.nextIf('with')) {
1291
+ for (;;) {
1292
+ const name = stream.expect(TokenType.NAME).value;
1293
+ let alias = name;
1294
+ if (stream.nextIf('as')) {
1295
+ alias = stream.expect(TokenType.NAME).value;
1296
+ }
1297
+ targets.push([name, alias]);
1298
+ if (!stream.nextIf(TokenType.PUNCTUATION, ',')) {
1299
+ break;
1300
+ }
1301
+ }
1302
+ }
1303
+
1304
+ stream.expect(TokenType.BLOCK_END);
1305
+
1306
+ parser.addTrait(n(NodeType.Use, { template, targets: n(NodeType.Nodes, { nodes: targets.map(([name, alias]) => n(NodeType.Constant, {}, { value: { name, alias } }, token.getLine())) }, {}, token.getLine()) }, {}, token.getLine()));
1307
+
1308
+ return n(NodeType.Use, { template, targets: n(NodeType.Nodes, { nodes: targets.map(([name, alias]) => n(NodeType.Constant, {}, { value: { name, alias } }, token.getLine())) }, {}, token.getLine()) }, {}, token.getLine());
1309
+ },
1310
+
1311
+ /** `{% with [expression [only]] %}...{% endwith %}` */
1312
+ with(parser, token) {
1313
+ const stream = parser.getStream();
1314
+ let variables = null;
1315
+ let only = false;
1316
+ if (!stream.test(TokenType.BLOCK_END)) {
1317
+ variables = parser.parseExpression();
1318
+ only = Boolean(stream.nextIf(TokenType.NAME, 'only'));
1319
+ }
1320
+ stream.expect(TokenType.BLOCK_END);
1321
+ const body = parser.subparse(decideTagEnd('endwith'), true);
1322
+ stream.expect(TokenType.BLOCK_END);
1323
+
1324
+ return n(NodeType.With, { body, variables }, { only }, token.getLine());
1325
+ },
1326
+
1327
+ /** `{% do expression %}` */
1328
+ do(parser, token) {
1329
+ const expression = parser.parseExpression();
1330
+ parser.getStream().expect(TokenType.BLOCK_END);
1331
+ return n(NodeType.Do, { expr: expression }, {}, token.getLine());
1332
+ },
1333
+
1334
+ /** `{% autoescape [strategy] %}...{% endautoescape %}` */
1335
+ autoescape(parser, token) {
1336
+ const lineNumber = token.getLine();
1337
+ const stream = parser.getStream();
1338
+ let value;
1339
+ if (stream.test(TokenType.BLOCK_END)) {
1340
+ value = 'html';
1341
+ } else {
1342
+ const expression = parser.parseExpression();
1343
+ if (expression.type !== NodeType.Constant) {
1344
+ throw new SyntaxError(
1345
+ 'An escaping strategy must be a string or false.',
1346
+ stream.getCurrent().getLine(),
1347
+ stream.getSourceContext(),
1348
+ );
1349
+ }
1350
+ value = expression.getAttribute('value');
1351
+ }
1352
+ stream.expect(TokenType.BLOCK_END);
1353
+ const body = parser.subparse(decideTagEnd('endautoescape'), true);
1354
+ stream.expect(TokenType.BLOCK_END);
1355
+ return n(NodeType.AutoEscape, { body }, { value }, lineNumber);
1356
+ },
1357
+
1358
+ /** `{% apply filter-chain %}...{% endapply %}` */
1359
+ apply(parser, token) {
1360
+ const lineNumber = token.getLine();
1361
+ const reference = n(NodeType.LocalVariable, {}, { name: null }, lineNumber);
1362
+ const filter = parser.expressionParser.parseFilterChain(reference);
1363
+ parser.getStream().expect(TokenType.BLOCK_END);
1364
+ const body = parser.subparse(decideTagEnd('endapply'), true);
1365
+ parser.getStream().expect(TokenType.BLOCK_END);
1366
+ const node = n(
1367
+ NodeType.Nodes,
1368
+ {
1369
+ nodes: [
1370
+ n(NodeType.Set, { names: n(NodeType.Nodes, { nodes: [reference] }, {}, lineNumber), values: n(NodeType.Nodes, { nodes: [body] }, {}, lineNumber), body }, { capture: true }, lineNumber),
1371
+ n(NodeType.Print, { expr: filter }, {}, lineNumber),
1372
+ ],
1373
+ },
1374
+ {},
1375
+ lineNumber,
1376
+ );
1377
+ node.setAttribute('desugared', 'apply');
1378
+ return node;
1379
+ },
1380
+
1381
+ /** `{% embed expression ... %}...{% endembed %}` */
1382
+ embed(parser, token) {
1383
+ const stream = parser.getStream();
1384
+ const parent = parser.parseExpression();
1385
+ const [variables, only, ignoreMissing] = parseIncludeArguments(parser);
1386
+
1387
+ let parentToken;
1388
+ if (parent.type === NodeType.Constant) {
1389
+ parentToken = new Token(TokenType.STRING, String(parent.getAttribute('value')), token.getLine());
1390
+ } else if (parent.type === NodeType.ContextVariable) {
1391
+ parentToken = new Token(TokenType.NAME, String(parent.getAttribute('name')), token.getLine());
1392
+ } else {
1393
+ parentToken = new Token(TokenType.STRING, '__parent__', token.getLine());
1394
+ }
1395
+
1396
+ // inject a fake parent to make the parent() function work
1397
+ parentToken.synthetic = true;
1398
+ const injected = [
1399
+ new Token(TokenType.BLOCK_START, '', token.getLine()),
1400
+ new Token(TokenType.NAME, 'extends', token.getLine()),
1401
+ parentToken,
1402
+ new Token(TokenType.BLOCK_END, '', token.getLine()),
1403
+ ];
1404
+ for (const injectedToken of injected) {
1405
+ injectedToken.synthetic = true;
1406
+ }
1407
+ stream.injectTokens(injected);
1408
+
1409
+ const module = parser.parse(stream, decideTagEnd('endembed'), true);
1410
+
1411
+ // override the parent with the correct one
1412
+ if (parentToken.value === '__parent__') {
1413
+ module.setNode('parent', parent);
1414
+ }
1415
+
1416
+ parser.embedTemplate(module);
1417
+
1418
+ stream.expect(TokenType.BLOCK_END);
1419
+
1420
+ const node = n(
1421
+ NodeType.Embed,
1422
+ { variables, only: n(NodeType.Constant, {}, { value: only }, token.getLine()), ignoreMissing: n(NodeType.Constant, {}, { value: ignoreMissing }, token.getLine()) },
1423
+ { name: String(module.getAttribute('embeddedName')), index: module.getAttribute('index') },
1424
+ token.getLine(),
1425
+ );
1426
+ node.setAttribute('parent', parent);
1427
+ node.setAttribute('embedded', module);
1428
+ return node;
1429
+ },
1430
+
1431
+ /** `{% sandbox %}...{% endsandbox %}` */
1432
+ sandbox(parser, token) {
1433
+ const stream = parser.getStream();
1434
+ stream.expect(TokenType.BLOCK_END);
1435
+ const body = parser.subparse(decideTagEnd('endsandbox'), true);
1436
+ stream.expect(TokenType.BLOCK_END);
1437
+
1438
+ // only include tags are allowed
1439
+ if (body.type === NodeType.Include) {
1440
+ body.setAttribute('sandboxed', true);
1441
+ } else if (body.type === NodeType.Nodes) {
1442
+ for (const node of body.getNodes('nodes')) {
1443
+ if (node.type === NodeType.Text && isBlankText(String(node.getAttribute('data')))) {
1444
+ continue;
1445
+ }
1446
+ if (node.type !== NodeType.Include) {
1447
+ throw new SyntaxError(
1448
+ 'Only "include" tags are allowed within a "sandbox" section.',
1449
+ node.getTemplateLine(),
1450
+ stream.getSourceContext(),
1451
+ );
1452
+ }
1453
+ node.setAttribute('sandboxed', true);
1454
+ }
1455
+ } else if (body.type !== NodeType.Text) {
1456
+ throw new SyntaxError(
1457
+ 'Only "include" tags are allowed within a "sandbox" section.',
1458
+ body.getTemplateLine(),
1459
+ stream.getSourceContext(),
1460
+ );
1461
+ }
1462
+
1463
+ return n(NodeType.Sandbox, { body }, {}, token.getLine());
1464
+ },
1465
+
1466
+ /** `{% guard (function|filter|test) name ... %}...{% endguard %}` */
1467
+ guard(parser, token) {
1468
+ const stream = parser.getStream();
1469
+ const typeToken = stream.expect(TokenType.NAME);
1470
+ if (!['function', 'filter', 'test'].includes(String(typeToken.value))) {
1471
+ throw new SyntaxError(
1472
+ `Supported guard types are function, filter and test, "${typeToken.value}" given.`,
1473
+ typeToken.getLine(),
1474
+ stream.getSourceContext(),
1475
+ );
1476
+ }
1477
+ let name = stream.expect(TokenType.NAME).value;
1478
+ if (typeToken.value === 'test' && stream.test(TokenType.NAME)) {
1479
+ name = `${name} ${stream.getCurrent().value}`;
1480
+ stream.next();
1481
+ }
1482
+
1483
+ /** @type {boolean} */
1484
+ let exists;
1485
+ try {
1486
+ if (typeToken.value === 'function') {
1487
+ exists = parser.environment.getFunction(name, token.getLine()) !== null;
1488
+ } else if (typeToken.value === 'filter') {
1489
+ exists = parser.environment.getFilter(name, token.getLine()) !== null;
1490
+ } else {
1491
+ exists = parser.environment.getTest(name) !== null;
1492
+ }
1493
+ } catch (e) {
1494
+ exists = false;
1495
+ }
1496
+
1497
+ stream.expect(TokenType.BLOCK_END);
1498
+ let body;
1499
+ if (exists) {
1500
+ body = parser.subparse(decideGuardFork);
1501
+ } else {
1502
+ body = n(NodeType.Empty, {}, {}, token.getLine());
1503
+ parser.subparseIgnoreUnknownTwigCallables(decideGuardFork);
1504
+ }
1505
+ let elseBody = n(NodeType.Empty, {}, {}, token.getLine());
1506
+ if ('else' === stream.next().value) {
1507
+ stream.expect(TokenType.BLOCK_END);
1508
+ elseBody = parser.subparse(decideTagEnd('endguard'), true);
1509
+ }
1510
+ stream.expect(TokenType.BLOCK_END);
1511
+
1512
+ const node = n(NodeType.Nodes, { nodes: [exists ? body : elseBody] }, {}, token.getLine());
1513
+ node.setAttribute('guardType', typeToken.value);
1514
+ node.setAttribute('guardName', name);
1515
+ node.setAttribute('guardExists', exists);
1516
+ return node;
1517
+ },
1518
+
1519
+ /** `{% types ... %}` */
1520
+ types(parser, token) {
1521
+ const stream = parser.getStream();
1522
+ const enclosed = stream.nextIf(TokenType.PUNCTUATION, '{') !== null;
1523
+ /** @type {Array<{name: string, type: string, optional: boolean}>} */
1524
+ const types = [];
1525
+ let first = true;
1526
+ while (!(stream.test(TokenType.PUNCTUATION, '}') || stream.test(TokenType.BLOCK_END))) {
1527
+ if (!first) {
1528
+ stream.expect(TokenType.PUNCTUATION, ',', 'A type string must be followed by a comma');
1529
+ if (stream.test(TokenType.PUNCTUATION, '}') || stream.test(TokenType.BLOCK_END)) {
1530
+ break;
1531
+ }
1532
+ }
1533
+ first = false;
1534
+
1535
+ const nameToken = stream.expect(TokenType.NAME);
1536
+ let isOptional;
1537
+ if (stream.nextIf(TokenType.OPERATOR, '?:')) {
1538
+ isOptional = true;
1539
+ } else {
1540
+ isOptional = stream.nextIf(TokenType.OPERATOR, '?') !== null;
1541
+ stream.expect(TokenType.PUNCTUATION, ':', 'A type name must be followed by a colon (:)');
1542
+ }
1543
+ const valueToken = stream.expect(TokenType.STRING);
1544
+ types.push({ name: String(nameToken.value), type: String(valueToken.value), optional: isOptional });
1545
+ }
1546
+ if (enclosed) {
1547
+ stream.expect(TokenType.PUNCTUATION, '}', 'An opened mapping is not properly closed');
1548
+ }
1549
+ stream.expect(TokenType.BLOCK_END);
1550
+ return n(NodeType.Types, {}, { types, enclosed }, token.getLine());
1551
+ },
1552
+
1553
+ /** `{% deprecated expression [package=... version=...] %}` */
1554
+ deprecated(parser, token) {
1555
+ const stream = parser.getStream();
1556
+ const expression = parser.parseExpression();
1557
+ const node = n(NodeType.Deprecated, { expr: expression, package: null, version: null }, {}, token.getLine());
1558
+
1559
+ while (stream.test(TokenType.NAME)) {
1560
+ const k = stream.getCurrent().value;
1561
+ stream.next();
1562
+ stream.expect(TokenType.OPERATOR, '=');
1563
+ switch (k) {
1564
+ case 'package':
1565
+ node.setNode('package', parser.parseExpression());
1566
+ break;
1567
+ case 'version':
1568
+ node.setNode('version', parser.parseExpression());
1569
+ break;
1570
+ default:
1571
+ throw new SyntaxError(
1572
+ `Unknown "${k}" option.`,
1573
+ stream.getCurrent().getLine(),
1574
+ stream.getSourceContext(),
1575
+ );
1576
+ }
1577
+ }
1578
+ stream.expect(TokenType.BLOCK_END);
1579
+ return node;
1580
+ },
1581
+
1582
+ /** `{% flush %}` */
1583
+ flush(parser, token) {
1584
+ parser.getStream().expect(TokenType.BLOCK_END);
1585
+ return n(NodeType.Flush, {}, {}, token.getLine());
1586
+ },
1587
+ };
1588
+
1589
+ /**
1590
+ * Builds a subparse end test for a simple closing tag.
1591
+ *
1592
+ * @param {string} tag The closing tag name (e.g. `endset`).
1593
+ * @returns {(token: Token) => boolean}
1594
+ */
1595
+ export function decideTagEnd(tag) {
1596
+ return (token) => token.test(tag);
1597
+ }
1598
+
1599
+ /**
1600
+ * @param {Token} token
1601
+ * @returns {boolean}
1602
+ */
1603
+ function decideForFork(token) {
1604
+ return token.test(['else', 'endfor']);
1605
+ }
1606
+
1607
+ /**
1608
+ * @param {Token} token
1609
+ * @returns {boolean}
1610
+ */
1611
+ function decideIfFork(token) {
1612
+ return token.test(['elseif', 'else', 'endif']);
1613
+ }
1614
+
1615
+ /**
1616
+ * @param {Token} token
1617
+ * @returns {boolean}
1618
+ */
1619
+ function decideGuardFork(token) {
1620
+ return token.test(['else', 'endguard']);
1621
+ }
1622
+
1623
+ /**
1624
+ * Parses the argument list of `include`-style tags.
1625
+ *
1626
+ * @param {Parser} parser
1627
+ * @returns {[Node|null, boolean, boolean]} `[variables, only, ignoreMissing]`.
1628
+ */
1629
+ function parseIncludeArguments(parser) {
1630
+ const stream = parser.getStream();
1631
+ let ignoreMissing = false;
1632
+ if (stream.nextIf(TokenType.NAME, 'ignore')) {
1633
+ stream.expect(TokenType.NAME, 'missing');
1634
+ ignoreMissing = true;
1635
+ }
1636
+ let variables = null;
1637
+ if (stream.nextIf(TokenType.NAME, 'with')) {
1638
+ variables = parser.parseExpression();
1639
+ }
1640
+ let only = false;
1641
+ if (stream.nextIf(TokenType.NAME, 'only')) {
1642
+ only = true;
1643
+ }
1644
+ stream.expect(TokenType.BLOCK_END);
1645
+ return [variables, only, ignoreMissing];
1646
+ }
1647
+
1648
+ /**
1649
+ * Parses a macro argument definition list.
1650
+ *
1651
+ * @param {Parser} parser
1652
+ * @param {string} macroName
1653
+ * @returns {[Array<[string, Node]>, string|null]} `[arguments, variadicName]`.
1654
+ */
1655
+ function parseMacroDefinition(parser, macroName) {
1656
+ const stream = parser.getStream();
1657
+ /** @type {Array<[string, Node]>} */
1658
+ const argumentsList = [];
1659
+ /** @type {string|null} */
1660
+ let variadicName = null;
1661
+ stream.expect(TokenType.OPERATOR, '(', 'A list of arguments must begin with an opening parenthesis');
1662
+ while (!stream.test(TokenType.PUNCTUATION, ')')) {
1663
+ if (argumentsList.length) {
1664
+ stream.expect(TokenType.PUNCTUATION, ',', 'Arguments must be separated by a comma');
1665
+ if (stream.test(TokenType.PUNCTUATION, ')')) {
1666
+ break;
1667
+ }
1668
+ }
1669
+
1670
+ if (stream.nextIf(TokenType.OPERATOR, '...')) {
1671
+ const token = stream.expect(TokenType.NAME, null, 'A variadic argument must be a name');
1672
+ variadicName = String(token.value);
1673
+ if (RESERVED_WORDS.has(variadicName)) {
1674
+ throw new SyntaxError(
1675
+ `You cannot assign a value to "${variadicName}".`,
1676
+ token.getLine(),
1677
+ stream.getSourceContext(),
1678
+ );
1679
+ }
1680
+ if (argumentsList.some(([existing]) => existing === variadicName)) {
1681
+ throw new SyntaxError(
1682
+ `The variadic argument "${variadicName}" in macro "${macroName}" cannot have the same name as another argument.`,
1683
+ token.getLine(),
1684
+ stream.getSourceContext(),
1685
+ );
1686
+ }
1687
+ if (stream.test(TokenType.OPERATOR, '=')) {
1688
+ throw new SyntaxError(
1689
+ `The variadic argument "${variadicName}" in macro "${macroName}" cannot have a default value.`,
1690
+ token.getLine(),
1691
+ stream.getSourceContext(),
1692
+ );
1693
+ }
1694
+ if (stream.nextIf(TokenType.PUNCTUATION, ',') && !stream.test(TokenType.PUNCTUATION, ')')) {
1695
+ throw new SyntaxError(
1696
+ `The variadic argument "${variadicName}" in macro "${macroName}" must be the last one.`,
1697
+ token.getLine(),
1698
+ stream.getSourceContext(),
1699
+ );
1700
+ }
1701
+ break;
1702
+ }
1703
+
1704
+ const token = stream.expect(TokenType.NAME, null, 'An argument must be a name');
1705
+ const name = String(token.value);
1706
+ if (RESERVED_WORDS.has(name)) {
1707
+ throw new SyntaxError(
1708
+ `You cannot assign a value to "${name}".`,
1709
+ token.getLine(),
1710
+ stream.getSourceContext(),
1711
+ );
1712
+ }
1713
+ if (argumentsList.some(([existing]) => existing === name)) {
1714
+ throw new SyntaxError(
1715
+ `Argument "${name}" is defined twice for macro "${macroName}".`,
1716
+ token.getLine(),
1717
+ stream.getSourceContext(),
1718
+ );
1719
+ }
1720
+ if (name === 'varargs') {
1721
+ throw new SyntaxError(
1722
+ `The argument "varargs" in macro "${macroName}" cannot be defined because the variable "varargs" is reserved for arbitrary arguments.`,
1723
+ token.getLine(),
1724
+ stream.getSourceContext(),
1725
+ );
1726
+ }
1727
+ let defaultNode;
1728
+ const equals = stream.nextIf(TokenType.OPERATOR, '=');
1729
+ if (equals) {
1730
+ defaultNode = parser.parseExpression();
1731
+ } else {
1732
+ defaultNode = n(NodeType.Constant, {}, { value: null }, stream.getCurrent().getLine());
1733
+ defaultNode.setAttribute('is_implicit', true);
1734
+ }
1735
+
1736
+ if (!checkConstantExpression(defaultNode)) {
1737
+ throw new SyntaxError(
1738
+ 'A default value for an argument must be a constant (a boolean, a string, a number, a sequence, or a mapping).',
1739
+ token.getLine(),
1740
+ stream.getSourceContext(),
1741
+ );
1742
+ }
1743
+ argumentsList.push([name, defaultNode]);
1744
+ }
1745
+ stream.expect(TokenType.PUNCTUATION, ')', 'A list of arguments must be closed by a parenthesis');
1746
+ return [argumentsList, variadicName];
1747
+ }
1748
+
1749
+ /**
1750
+ * Whether a node is a constant expression.
1751
+ *
1752
+ * @param {Node} node
1753
+ * @returns {boolean}
1754
+ */
1755
+ function checkConstantExpression(node) {
1756
+ switch (node.type) {
1757
+ case NodeType.Constant:
1758
+ case NodeType.ArrayExpr:
1759
+ case NodeType.Unary:
1760
+ break;
1761
+ default:
1762
+ return false;
1763
+ }
1764
+ if (node.type === NodeType.Unary) {
1765
+ const operator = node.getAttribute('operator');
1766
+ if (operator !== '-' && operator !== '+') {
1767
+ return false;
1768
+ }
1769
+ }
1770
+ for (const child of node) {
1771
+ if (!checkConstantExpression(child)) {
1772
+ return false;
1773
+ }
1774
+ }
1775
+ return true;
1776
+ }