@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/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Public API of the `@mrhenry/twig-parser` package.
|
|
4
|
+
*
|
|
5
|
+
* @module twig-parser
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { Node, NodeType, n } from './node.js';
|
|
9
|
+
export { printNode } from './printer.js';
|
|
10
|
+
export { ArrayExpression } from './array-expression.js';
|
|
11
|
+
export { ExpressionParser, BINARY_INFORMATION, PREFIX_OPERATORS, POSTFIX_OPERATORS } from './expression-parser.js';
|
|
12
|
+
export { Parser, TAG_PARSERS, BLOCK_TAGS, INLINE_TAGS, TAG_END_NAMES, isBlankText, decideTagEnd } from './parser.js';
|
|
13
|
+
export {
|
|
14
|
+
CORE_FILTER_NAMES,
|
|
15
|
+
CORE_FUNCTION_NAMES,
|
|
16
|
+
CORE_TEST_NAMES,
|
|
17
|
+
PARSER_CALLABLE_FUNCTIONS,
|
|
18
|
+
ONE_MANDATORY_ARGUMENT_TESTS,
|
|
19
|
+
} from './callables.js';
|
package/src/node.js
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The AST node model for the Twig parser.
|
|
4
|
+
*
|
|
5
|
+
* @module twig-parser
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { printNode } from './printer.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {import('@mrhenry/twig-tokenizer').Source} Source
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A single AST node.
|
|
16
|
+
*
|
|
17
|
+
* Nodes mirror the reference implementation's `Twig\Node\Node`: they carry
|
|
18
|
+
* child nodes (indexed by name) and a flat set of attributes. The {@link Node#type}
|
|
19
|
+
* property discriminates between node kinds (see `NodeType`).
|
|
20
|
+
*
|
|
21
|
+
* Children may be nodes, arrays of nodes, or other metadata values depending on
|
|
22
|
+
* the node kind; {@link Node#getNode} and {@link Node#getNodes} provide typed
|
|
23
|
+
* accessors for the node-valued children.
|
|
24
|
+
*/
|
|
25
|
+
export class Node {
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} type The node kind (one of {@link NodeType}).
|
|
28
|
+
* @param {Record<string, unknown>} [children] Named children.
|
|
29
|
+
* @param {Record<string, unknown>} [attributes] Node attributes.
|
|
30
|
+
* @param {number} [lineNumber] The template line number.
|
|
31
|
+
* @param {string|null} [tag] The originating tag name.
|
|
32
|
+
*/
|
|
33
|
+
constructor(type, children = {}, attributes = {}, lineNumber = 0, tag = null) {
|
|
34
|
+
/** @type {string} */
|
|
35
|
+
this.type = type;
|
|
36
|
+
/** @type {Record<string, unknown>} */
|
|
37
|
+
this.children = children;
|
|
38
|
+
/** @type {Record<string, unknown>} */
|
|
39
|
+
this.attributes = attributes;
|
|
40
|
+
/** @type {number} */
|
|
41
|
+
this.templateLine = lineNumber;
|
|
42
|
+
/** @type {string|null} */
|
|
43
|
+
this.tag = tag;
|
|
44
|
+
/** @type {Source|null} */
|
|
45
|
+
this.sourceContext = null;
|
|
46
|
+
/** @type {boolean} */
|
|
47
|
+
this.explicitParentheses = false;
|
|
48
|
+
|
|
49
|
+
/** Exact source text covered by this node (null when not tied to source). */
|
|
50
|
+
/** @type {string|null} */
|
|
51
|
+
this.raw = null;
|
|
52
|
+
/** Trivia preceding this node's source span. */
|
|
53
|
+
/** @type {string} */
|
|
54
|
+
this.leading = '';
|
|
55
|
+
/** Absolute source offset where the node's span starts. */
|
|
56
|
+
/** @type {number} */
|
|
57
|
+
this.rawStart = 0;
|
|
58
|
+
/** Absolute source offset just past the node's span. */
|
|
59
|
+
/** @type {number} */
|
|
60
|
+
this.rawEnd = 0;
|
|
61
|
+
/** Signature captured when the node was parsed, or null. */
|
|
62
|
+
/** @type {string|null} */
|
|
63
|
+
this.originalSignature = null;
|
|
64
|
+
/** Signature computed during the latest serialization pass. */
|
|
65
|
+
/** @type {string|null} */
|
|
66
|
+
this.currentSignature = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} key
|
|
71
|
+
* @returns {Node|null} The child node, or null when the child is absent or not a node.
|
|
72
|
+
*/
|
|
73
|
+
getNode(key) {
|
|
74
|
+
const child = this.children[key];
|
|
75
|
+
if (Array.isArray(child)) {
|
|
76
|
+
const first = child[0];
|
|
77
|
+
return first instanceof Node ? first : null;
|
|
78
|
+
}
|
|
79
|
+
return child instanceof Node ? child : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} key
|
|
84
|
+
* @returns {Node[]} The node-valued elements of a list child, or [].
|
|
85
|
+
*/
|
|
86
|
+
getNodes(key) {
|
|
87
|
+
const child = this.children[key];
|
|
88
|
+
if (Array.isArray(child)) {
|
|
89
|
+
return child.filter((item) => item instanceof Node);
|
|
90
|
+
}
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {string} key
|
|
96
|
+
* @param {unknown} node
|
|
97
|
+
*/
|
|
98
|
+
setNode(key, node) {
|
|
99
|
+
this.children[key] = node;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {string} key
|
|
104
|
+
* @returns {boolean} Whether a node-valued child exists.
|
|
105
|
+
*/
|
|
106
|
+
hasNode(key) {
|
|
107
|
+
const child = this.children[key];
|
|
108
|
+
return child instanceof Node || (Array.isArray(child) && child.some((item) => item instanceof Node));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {string} key
|
|
113
|
+
*/
|
|
114
|
+
removeNode(key) {
|
|
115
|
+
delete this.children[key];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @template {unknown} [T=unknown]
|
|
120
|
+
* @param {string} key
|
|
121
|
+
* @returns {T|undefined} The attribute value.
|
|
122
|
+
*/
|
|
123
|
+
getAttribute(key) {
|
|
124
|
+
return /** @type {T|undefined} */ (this.attributes[key]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @template {unknown} [T=unknown]
|
|
129
|
+
* @param {string} key
|
|
130
|
+
* @param {T} [defaultValue]
|
|
131
|
+
* @returns {T|undefined} The attribute value or the default.
|
|
132
|
+
*/
|
|
133
|
+
getAttributeOr(key, defaultValue) {
|
|
134
|
+
const value = this.attributes[key];
|
|
135
|
+
return value === undefined ? defaultValue : /** @type {T} */ (value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {string} key
|
|
140
|
+
* @param {unknown} value
|
|
141
|
+
*/
|
|
142
|
+
setAttribute(key, value) {
|
|
143
|
+
this.attributes[key] = value;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @returns {number} The template line number.
|
|
148
|
+
*/
|
|
149
|
+
getTemplateLine() {
|
|
150
|
+
return this.templateLine;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @returns {string|null} The originating tag name.
|
|
155
|
+
*/
|
|
156
|
+
getNodeTag() {
|
|
157
|
+
return this.tag;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {string} tag
|
|
162
|
+
*/
|
|
163
|
+
setNodeTag(tag) {
|
|
164
|
+
this.tag = tag;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @returns {Source|null} The template source context.
|
|
169
|
+
*/
|
|
170
|
+
getSourceContext() {
|
|
171
|
+
return this.sourceContext;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {Source|null} source
|
|
176
|
+
*/
|
|
177
|
+
setSourceContext(source) {
|
|
178
|
+
this.sourceContext = source;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Marks the node as wrapped in explicit parentheses (affects precedence
|
|
183
|
+
* deprecations in the reference implementation).
|
|
184
|
+
*/
|
|
185
|
+
setExplicitParentheses() {
|
|
186
|
+
this.explicitParentheses = true;
|
|
187
|
+
return this;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @returns {boolean} Whether the node has explicit parentheses.
|
|
192
|
+
*/
|
|
193
|
+
hasExplicitParentheses() {
|
|
194
|
+
return this.explicitParentheses;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Serializes this node (and its subtree) back to Twig source.
|
|
199
|
+
*
|
|
200
|
+
* When nothing in the subtree was mutated the exact source span captured at
|
|
201
|
+
* parse time is returned (lossless). When the subtree was mutated, the node
|
|
202
|
+
* is re-printed from its current contents, reusing the retained source of
|
|
203
|
+
* any unchanged descendants.
|
|
204
|
+
*
|
|
205
|
+
* @returns {string} The serialized Twig source.
|
|
206
|
+
*/
|
|
207
|
+
serialize() {
|
|
208
|
+
refreshSignatures(this);
|
|
209
|
+
return this._serialize();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Serializes using the signatures computed by the most recent
|
|
214
|
+
* {@link refreshSignatures} pass.
|
|
215
|
+
*
|
|
216
|
+
* @returns {string} The serialized Twig source.
|
|
217
|
+
*/
|
|
218
|
+
_serialize() {
|
|
219
|
+
if (this.raw !== null && this.currentSignature === this.originalSignature) {
|
|
220
|
+
return this.leading + this.raw;
|
|
221
|
+
}
|
|
222
|
+
return printNode(this);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* @returns {string} The leading trivia of this node.
|
|
227
|
+
*/
|
|
228
|
+
getLeading() {
|
|
229
|
+
return this.leading;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @returns {string|null} The retained source text of this node.
|
|
234
|
+
*/
|
|
235
|
+
getRaw() {
|
|
236
|
+
return this.raw;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Iterates over all direct child nodes (flattening list children and
|
|
241
|
+
* skipping non-node metadata values).
|
|
242
|
+
*
|
|
243
|
+
* @returns {Generator<Node>}
|
|
244
|
+
*/
|
|
245
|
+
*[Symbol.iterator]() {
|
|
246
|
+
for (const key of Object.keys(this.children)) {
|
|
247
|
+
const child = this.children[key];
|
|
248
|
+
if (child === null || child === undefined) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (Array.isArray(child)) {
|
|
252
|
+
for (const item of child) {
|
|
253
|
+
if (item instanceof Node) {
|
|
254
|
+
yield item;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} else if (child instanceof Node) {
|
|
258
|
+
yield child;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Recursively visits this node and all descendants.
|
|
265
|
+
*
|
|
266
|
+
* @param {(node: Node) => void} callback Visitor callback.
|
|
267
|
+
*/
|
|
268
|
+
traverse(callback) {
|
|
269
|
+
callback(this);
|
|
270
|
+
for (const child of this) {
|
|
271
|
+
child.traverse(callback);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Node kinds produced by the parser.
|
|
278
|
+
*
|
|
279
|
+
* @readonly
|
|
280
|
+
* @enum {string}
|
|
281
|
+
*/
|
|
282
|
+
export const NodeType = {
|
|
283
|
+
// module / structure
|
|
284
|
+
Module: 'module',
|
|
285
|
+
Body: 'body',
|
|
286
|
+
Nodes: 'nodes',
|
|
287
|
+
Text: 'text',
|
|
288
|
+
Print: 'print',
|
|
289
|
+
Empty: 'empty',
|
|
290
|
+
|
|
291
|
+
// expressions
|
|
292
|
+
Constant: 'constant',
|
|
293
|
+
ContextVariable: 'context_variable',
|
|
294
|
+
AssignContextVariable: 'assign_context_variable',
|
|
295
|
+
LocalVariable: 'local_variable',
|
|
296
|
+
ArrayExpr: 'array',
|
|
297
|
+
ListExpr: 'list',
|
|
298
|
+
GetAttr: 'get_attr',
|
|
299
|
+
MacroReference: 'macro_reference',
|
|
300
|
+
Filter: 'filter',
|
|
301
|
+
FunctionCall: 'function',
|
|
302
|
+
Test: 'test',
|
|
303
|
+
ArrowFunction: 'arrow_function',
|
|
304
|
+
Binary: 'binary',
|
|
305
|
+
Unary: 'unary',
|
|
306
|
+
SetBinary: 'set',
|
|
307
|
+
SequenceDestructuringSet: 'sequence_destructuring_set',
|
|
308
|
+
ObjectDestructuringSet: 'object_destructuring_set',
|
|
309
|
+
Conditional: 'conditional',
|
|
310
|
+
|
|
311
|
+
// tags
|
|
312
|
+
Block: 'block',
|
|
313
|
+
BlockReference: 'block_reference',
|
|
314
|
+
For: 'for',
|
|
315
|
+
If: 'if',
|
|
316
|
+
Set: 'set',
|
|
317
|
+
Include: 'include',
|
|
318
|
+
Import: 'import',
|
|
319
|
+
Macro: 'macro',
|
|
320
|
+
MacroDeclaration: 'macro_declaration',
|
|
321
|
+
Macros: 'macros',
|
|
322
|
+
With: 'with',
|
|
323
|
+
Do: 'do',
|
|
324
|
+
Extends: 'extends',
|
|
325
|
+
Use: 'use',
|
|
326
|
+
Embed: 'embed',
|
|
327
|
+
AutoEscape: 'autoescape',
|
|
328
|
+
Apply: 'apply',
|
|
329
|
+
Sandbox: 'sandbox',
|
|
330
|
+
Deprecated: 'deprecated',
|
|
331
|
+
Flush: 'flush',
|
|
332
|
+
Guard: 'guard',
|
|
333
|
+
Types: 'types',
|
|
334
|
+
CheckSecurity: 'check_security',
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Creates a node.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} type The node kind.
|
|
341
|
+
* @param {Record<string, unknown>} [children] Named children.
|
|
342
|
+
* @param {Record<string, unknown>} [attributes] Attributes.
|
|
343
|
+
* @param {number} [lineNumber] Line number.
|
|
344
|
+
* @param {string|null} [tag] Tag name.
|
|
345
|
+
* @returns {Node} The created node.
|
|
346
|
+
*/
|
|
347
|
+
export function n(type, children = {}, attributes = {}, lineNumber = 0, tag = null) {
|
|
348
|
+
return new Node(type, children, attributes, lineNumber, tag);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* A tiny FNV-1a hash, used to compare node subtrees cheaply.
|
|
353
|
+
*
|
|
354
|
+
* @param {string} text
|
|
355
|
+
* @returns {string} A base-36 digest.
|
|
356
|
+
*/
|
|
357
|
+
function fnv1a(text) {
|
|
358
|
+
let hash = 0x811c9dc5;
|
|
359
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
360
|
+
hash ^= text.charCodeAt(i);
|
|
361
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
362
|
+
}
|
|
363
|
+
return hash.toString(36);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Computes a content signature for a value contained in a node (children or
|
|
368
|
+
* attributes). Nodes are folded in via their own content signature.
|
|
369
|
+
*
|
|
370
|
+
* @param {unknown} value
|
|
371
|
+
* @param {WeakMap<Node, string>} cache
|
|
372
|
+
* @returns {string}
|
|
373
|
+
*/
|
|
374
|
+
function valueSignature(value, cache) {
|
|
375
|
+
if (value instanceof Node) {
|
|
376
|
+
return `N${computeSignature(value, cache)}`;
|
|
377
|
+
}
|
|
378
|
+
if (Array.isArray(value)) {
|
|
379
|
+
return `[${value.map((item) => valueSignature(item, cache)).join(',')}]`;
|
|
380
|
+
}
|
|
381
|
+
if (value !== null && typeof value === 'object') {
|
|
382
|
+
const object = /** @type {Record<string, unknown>} */ (value);
|
|
383
|
+
return `{${Object.keys(object)
|
|
384
|
+
.sort()
|
|
385
|
+
.map((key) => `${key}:${valueSignature(object[key], cache)}`)
|
|
386
|
+
.join(',')}}`;
|
|
387
|
+
}
|
|
388
|
+
return String(value);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Computes the content signature of a node, memoised in `cache`.
|
|
393
|
+
*
|
|
394
|
+
* @param {Node} node
|
|
395
|
+
* @param {WeakMap<Node, string>} cache
|
|
396
|
+
* @returns {string}
|
|
397
|
+
*/
|
|
398
|
+
export function computeSignature(node, cache) {
|
|
399
|
+
const cached = cache.get(node);
|
|
400
|
+
if (cached !== undefined) {
|
|
401
|
+
return cached;
|
|
402
|
+
}
|
|
403
|
+
let material = `${node.type}\u0000${node.tag ?? ''}\u0000${node.explicitParentheses}`;
|
|
404
|
+
for (const key of Object.keys(node.attributes).sort()) {
|
|
405
|
+
material += `\u0001${key}=${valueSignature(node.attributes[key], cache)}`;
|
|
406
|
+
}
|
|
407
|
+
for (const key of Object.keys(node.children).sort()) {
|
|
408
|
+
material += `\u0002${key}=${valueSignature(node.children[key], cache)}`;
|
|
409
|
+
}
|
|
410
|
+
const signature = fnv1a(material);
|
|
411
|
+
cache.set(node, signature);
|
|
412
|
+
return signature;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Visits every node reachable from `node` through children and attributes.
|
|
417
|
+
*
|
|
418
|
+
* @param {Node} node
|
|
419
|
+
* @param {(node: Node) => void} callback
|
|
420
|
+
* @param {WeakSet<Node>} [seen]
|
|
421
|
+
*/
|
|
422
|
+
function walkNodes(node, callback, seen = new WeakSet()) {
|
|
423
|
+
if (seen.has(node)) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
seen.add(node);
|
|
427
|
+
callback(node);
|
|
428
|
+
/** @param {unknown} value */
|
|
429
|
+
const visit = (value) => {
|
|
430
|
+
if (value instanceof Node) {
|
|
431
|
+
walkNodes(value, callback, seen);
|
|
432
|
+
} else if (Array.isArray(value)) {
|
|
433
|
+
for (const item of value) {
|
|
434
|
+
visit(item);
|
|
435
|
+
}
|
|
436
|
+
} else if (value !== null && typeof value === 'object') {
|
|
437
|
+
for (const item of Object.values(/** @type {Record<string, unknown>} */ (value))) {
|
|
438
|
+
visit(item);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
for (const value of Object.values(node.children)) {
|
|
443
|
+
visit(value);
|
|
444
|
+
}
|
|
445
|
+
for (const value of Object.values(node.attributes)) {
|
|
446
|
+
visit(value);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Captures the original content signature of every node in a tree.
|
|
452
|
+
*
|
|
453
|
+
* @param {Node} root
|
|
454
|
+
*/
|
|
455
|
+
export function captureSignatures(root) {
|
|
456
|
+
const cache = new WeakMap();
|
|
457
|
+
computeSignature(root, cache);
|
|
458
|
+
walkNodes(root, (node) => {
|
|
459
|
+
node.originalSignature = cache.get(node) ?? null;
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Recomputes the current content signature of every node in a tree.
|
|
465
|
+
*
|
|
466
|
+
* @param {Node} root
|
|
467
|
+
*/
|
|
468
|
+
export function refreshSignatures(root) {
|
|
469
|
+
const cache = new WeakMap();
|
|
470
|
+
computeSignature(root, cache);
|
|
471
|
+
walkNodes(root, (node) => {
|
|
472
|
+
node.currentSignature = cache.get(node) ?? null;
|
|
473
|
+
});
|
|
474
|
+
}
|