@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/LICENSE +24 -0
- package/package.json +13 -0
- package/src/array-expression.js +69 -0
- package/src/callables.js +127 -0
- package/src/expression-parser.js +1103 -0
- package/src/index.js +19 -0
- package/src/node.js +474 -0
- package/src/parser.js +1776 -0
- package/src/printer.js +752 -0
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
|
+
}
|