@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/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
+ }