@mrhenry/twig-parser 0.1.1 → 0.1.2
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/CHANGELOG.md +19 -0
- package/package.json +2 -2
- package/src/callables.js +1 -0
- package/src/expression-parser.js +37 -0
- package/src/node.js +88 -70
- package/src/parser.js +58 -2
package/CHANGELOG.md
CHANGED
|
@@ -1 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.2 (2026-09-26)
|
|
4
|
+
|
|
5
|
+
* Added the sandbox `render_sandboxed` function to the core function set, so it
|
|
6
|
+
can be parsed (its `output_strategy` argument is validated at compile time).
|
|
7
|
+
|
|
8
|
+
* Signature capture now defaults to off. Signature capture backs lossless
|
|
9
|
+
`Node#serialize` (it lets a node detect whether its subtree was mutated) and
|
|
10
|
+
hashes the whole tree during parsing, so it is now opt-in via
|
|
11
|
+
`{ captureSignatures: true }`. Parsers that mutate and serialize the AST
|
|
12
|
+
must now pass it; compile-only callers (like `@mrhenry/twig-js`) pay nothing.
|
|
13
|
+
* Signature computation now hashes incrementally instead of building an
|
|
14
|
+
intermediate string per node, and captures the whole tree in a single walk.
|
|
15
|
+
|
|
16
|
+
* Bounded parser recursion. `parseExpression` and `subparse` now track their
|
|
17
|
+
own depth (cap `256`) and throw a `SyntaxError` — "Expression is too deeply
|
|
18
|
+
nested." / "Template nesting is too deep." — instead of overflowing the call
|
|
19
|
+
stack. Previously a crafted template of nested `not not …`, `((( … )))`,
|
|
20
|
+
`[[[ … ]]]` or `{% if %}` tags crashed the process with a `RangeError`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrhenry/twig-parser",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
".": "./src/index.js"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@mrhenry/twig-tokenizer": "^0.1.
|
|
11
|
+
"@mrhenry/twig-tokenizer": "^0.1.2"
|
|
12
12
|
}
|
|
13
13
|
}
|
package/src/callables.js
CHANGED
package/src/expression-parser.js
CHANGED
|
@@ -21,6 +21,18 @@ import { ArrayExpression } from './array-expression.js';
|
|
|
21
21
|
/** Name pattern used to accept word-operators as variable names. */
|
|
22
22
|
const REGULAR_EXPRESSION_NAME = /^[a-zA-Z_\u007f-\uffff][a-zA-Z0-9_\u007f-\uffff]*$/;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The deepest expression nesting the precedence-climbing parser accepts before
|
|
26
|
+
* it refuses to recurse further. Prefix chains (`not not …`, `---…`), grouped
|
|
27
|
+
* expressions and nested sequences/mappings all recurse through
|
|
28
|
+
* {@link ExpressionParser.parseExpression}; without a bound a crafted template
|
|
29
|
+
* would grow the call stack until it overflows the process.
|
|
30
|
+
*
|
|
31
|
+
* Well below the point at which a real stack overflow occurs, so the failure is
|
|
32
|
+
* a controlled {@link SyntaxError} rather than a `RangeError`.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_EXPRESSION_DEPTH = 256;
|
|
35
|
+
|
|
24
36
|
/**
|
|
25
37
|
* Binary operator metadata: precedence and associativity.
|
|
26
38
|
*
|
|
@@ -124,6 +136,8 @@ export class ExpressionParser {
|
|
|
124
136
|
constructor(parser) {
|
|
125
137
|
/** @type {import('./parser.js').Parser} */
|
|
126
138
|
this.parser = parser;
|
|
139
|
+
/** @type {number} Current recursion depth of {@link parseExpression}. */
|
|
140
|
+
this.expressionDepth = 0;
|
|
127
141
|
}
|
|
128
142
|
|
|
129
143
|
/**
|
|
@@ -147,6 +161,29 @@ export class ExpressionParser {
|
|
|
147
161
|
* @returns {Node} The parsed expression node.
|
|
148
162
|
*/
|
|
149
163
|
parseExpression(precedence = 0) {
|
|
164
|
+
if (this.expressionDepth >= MAX_EXPRESSION_DEPTH) {
|
|
165
|
+
const token = this.current();
|
|
166
|
+
throw new SyntaxError(
|
|
167
|
+
'Expression is too deeply nested.',
|
|
168
|
+
token.getLine(),
|
|
169
|
+
this.getStream().getSourceContext(),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
this.expressionDepth += 1;
|
|
173
|
+
try {
|
|
174
|
+
return this.parseExpressionInternal(precedence);
|
|
175
|
+
} finally {
|
|
176
|
+
this.expressionDepth -= 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The unguarded recursive body of {@link parseExpression}.
|
|
182
|
+
*
|
|
183
|
+
* @param {number} precedence The minimum precedence of infix operators to consume.
|
|
184
|
+
* @returns {Node} The parsed expression node.
|
|
185
|
+
*/
|
|
186
|
+
parseExpressionInternal(precedence = 0) {
|
|
150
187
|
const stream = this.getStream();
|
|
151
188
|
const token = this.current();
|
|
152
189
|
let expression;
|
package/src/node.js
CHANGED
|
@@ -349,43 +349,85 @@ export function n(type, children = {}, attributes = {}, lineNumber = 0, tag = nu
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
/**
|
|
352
|
-
* A
|
|
352
|
+
* A streaming FNV-1a hasher, used to compare node subtrees cheaply.
|
|
353
353
|
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
354
|
+
* Hashing is incremental so signatures never build intermediate strings: each
|
|
355
|
+
* code unit is folded into the running hash as it is visited.
|
|
356
356
|
*/
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
hash
|
|
361
|
-
|
|
357
|
+
class Fnv1a {
|
|
358
|
+
constructor() {
|
|
359
|
+
/** @type {number} */
|
|
360
|
+
this.hash = 0x811c9dc5;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* @param {number} codeUnit
|
|
365
|
+
*/
|
|
366
|
+
byte(codeUnit) {
|
|
367
|
+
this.hash ^= codeUnit;
|
|
368
|
+
this.hash = Math.imul(this.hash, 0x01000193) >>> 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* @param {string} text
|
|
373
|
+
*/
|
|
374
|
+
str(text) {
|
|
375
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
376
|
+
this.byte(text.charCodeAt(i));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* @returns {string} A base-36 digest.
|
|
382
|
+
*/
|
|
383
|
+
digest() {
|
|
384
|
+
return this.hash.toString(36);
|
|
362
385
|
}
|
|
363
|
-
return hash.toString(36);
|
|
364
386
|
}
|
|
365
387
|
|
|
366
388
|
/**
|
|
367
|
-
*
|
|
368
|
-
*
|
|
389
|
+
* Folds a value contained in a node (children or attributes) into `hasher`.
|
|
390
|
+
* Nodes are folded in via their own content signature.
|
|
369
391
|
*
|
|
392
|
+
* @param {Fnv1a} hasher
|
|
370
393
|
* @param {unknown} value
|
|
371
394
|
* @param {WeakMap<Node, string>} cache
|
|
372
|
-
* @
|
|
395
|
+
* @param {'originalSignature'|'currentSignature'|null} field The node property to capture the signature on, if any.
|
|
373
396
|
*/
|
|
374
|
-
function
|
|
397
|
+
function hashValue(hasher, value, cache, field) {
|
|
375
398
|
if (value instanceof Node) {
|
|
376
|
-
|
|
399
|
+
hasher.str('N');
|
|
400
|
+
hasher.str(computeSignature(value, cache, field));
|
|
401
|
+
return;
|
|
377
402
|
}
|
|
378
403
|
if (Array.isArray(value)) {
|
|
379
|
-
|
|
404
|
+
hasher.str('[');
|
|
405
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
406
|
+
if (i > 0) {
|
|
407
|
+
hasher.str(',');
|
|
408
|
+
}
|
|
409
|
+
hashValue(hasher, value[i], cache, field);
|
|
410
|
+
}
|
|
411
|
+
hasher.str(']');
|
|
412
|
+
return;
|
|
380
413
|
}
|
|
381
414
|
if (value !== null && typeof value === 'object') {
|
|
382
415
|
const object = /** @type {Record<string, unknown>} */ (value);
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
416
|
+
hasher.str('{');
|
|
417
|
+
const keys = Object.keys(object).sort();
|
|
418
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
419
|
+
const key = keys[i];
|
|
420
|
+
if (i > 0) {
|
|
421
|
+
hasher.str(',');
|
|
422
|
+
}
|
|
423
|
+
hasher.str(key);
|
|
424
|
+
hasher.str(':');
|
|
425
|
+
hashValue(hasher, object[key], cache, field);
|
|
426
|
+
}
|
|
427
|
+
hasher.str('}');
|
|
428
|
+
return;
|
|
387
429
|
}
|
|
388
|
-
|
|
430
|
+
hasher.str(String(value));
|
|
389
431
|
}
|
|
390
432
|
|
|
391
433
|
/**
|
|
@@ -393,58 +435,42 @@ function valueSignature(value, cache) {
|
|
|
393
435
|
*
|
|
394
436
|
* @param {Node} node
|
|
395
437
|
* @param {WeakMap<Node, string>} cache
|
|
438
|
+
* @param {'originalSignature'|'currentSignature'|null} [field] When set, the
|
|
439
|
+
* signature is also stored on this node property, so a single traversal
|
|
440
|
+
* captures the whole tree.
|
|
396
441
|
* @returns {string}
|
|
397
442
|
*/
|
|
398
|
-
export function computeSignature(node, cache) {
|
|
443
|
+
export function computeSignature(node, cache, field = null) {
|
|
399
444
|
const cached = cache.get(node);
|
|
400
445
|
if (cached !== undefined) {
|
|
401
446
|
return cached;
|
|
402
447
|
}
|
|
403
|
-
|
|
448
|
+
const hasher = new Fnv1a();
|
|
449
|
+
hasher.str(node.type);
|
|
450
|
+
hasher.byte(0);
|
|
451
|
+
hasher.str(node.tag ?? '');
|
|
452
|
+
hasher.byte(0);
|
|
453
|
+
hasher.str(String(node.explicitParentheses));
|
|
404
454
|
for (const key of Object.keys(node.attributes).sort()) {
|
|
405
|
-
|
|
455
|
+
hasher.byte(1);
|
|
456
|
+
hasher.str(key);
|
|
457
|
+
hasher.str('=');
|
|
458
|
+
hashValue(hasher, node.attributes[key], cache, field);
|
|
406
459
|
}
|
|
407
460
|
for (const key of Object.keys(node.children).sort()) {
|
|
408
|
-
|
|
461
|
+
hasher.byte(2);
|
|
462
|
+
hasher.str(key);
|
|
463
|
+
hasher.str('=');
|
|
464
|
+
hashValue(hasher, node.children[key], cache, field);
|
|
409
465
|
}
|
|
410
|
-
const signature =
|
|
466
|
+
const signature = hasher.digest();
|
|
411
467
|
cache.set(node, signature);
|
|
412
|
-
|
|
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);
|
|
468
|
+
if (field === 'originalSignature') {
|
|
469
|
+
node.originalSignature = signature;
|
|
470
|
+
} else if (field === 'currentSignature') {
|
|
471
|
+
node.currentSignature = signature;
|
|
447
472
|
}
|
|
473
|
+
return signature;
|
|
448
474
|
}
|
|
449
475
|
|
|
450
476
|
/**
|
|
@@ -453,11 +479,7 @@ function walkNodes(node, callback, seen = new WeakSet()) {
|
|
|
453
479
|
* @param {Node} root
|
|
454
480
|
*/
|
|
455
481
|
export function captureSignatures(root) {
|
|
456
|
-
|
|
457
|
-
computeSignature(root, cache);
|
|
458
|
-
walkNodes(root, (node) => {
|
|
459
|
-
node.originalSignature = cache.get(node) ?? null;
|
|
460
|
-
});
|
|
482
|
+
computeSignature(root, new WeakMap(), 'originalSignature');
|
|
461
483
|
}
|
|
462
484
|
|
|
463
485
|
/**
|
|
@@ -466,9 +488,5 @@ export function captureSignatures(root) {
|
|
|
466
488
|
* @param {Node} root
|
|
467
489
|
*/
|
|
468
490
|
export function refreshSignatures(root) {
|
|
469
|
-
|
|
470
|
-
computeSignature(root, cache);
|
|
471
|
-
walkNodes(root, (node) => {
|
|
472
|
-
node.currentSignature = cache.get(node) ?? null;
|
|
473
|
-
});
|
|
491
|
+
computeSignature(root, new WeakMap(), 'currentSignature');
|
|
474
492
|
}
|
package/src/parser.js
CHANGED
|
@@ -40,12 +40,29 @@ import {
|
|
|
40
40
|
* @property {(name: string) => CallableDescriptor|null} getTest
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Parser options.
|
|
45
|
+
*
|
|
46
|
+
* @typedef {object} ParserOptions
|
|
47
|
+
* @property {boolean} [captureSignatures] Capture original content signatures
|
|
48
|
+
* for lossless {@link Node#serialize} support (default false). Set to true
|
|
49
|
+
* before parsing when the resulting AST will be mutated and serialized.
|
|
50
|
+
*/
|
|
51
|
+
|
|
43
52
|
/** Name pattern for word-operators used as variable names. */
|
|
44
53
|
const REGULAR_EXPRESSION_NAME = /^[a-zA-Z_\u007f-\uffff][a-zA-Z0-9_\u007f-\uffff]*$/;
|
|
45
54
|
|
|
46
55
|
/** Reserved literal words that cannot be assigned to. */
|
|
47
56
|
const RESERVED_WORDS = new Set(['true', 'TRUE', 'false', 'FALSE', 'none', 'NONE', 'null', 'NULL']);
|
|
48
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The deepest tag nesting {@link Parser.subparse} accepts. Every block tag
|
|
60
|
+
* (`if`, `for`, `block`, `macro`, `embed`, …) recurses back into `subparse`, so
|
|
61
|
+
* without a bound a template of nested `{% if %}` tags would overflow the call
|
|
62
|
+
* stack. The limit fails with a controlled {@link SyntaxError} first.
|
|
63
|
+
*/
|
|
64
|
+
const MAX_SUBPARSE_DEPTH = 256;
|
|
65
|
+
|
|
49
66
|
/**
|
|
50
67
|
* An imported macro symbol.
|
|
51
68
|
*
|
|
@@ -81,10 +98,21 @@ const RESERVED_WORDS = new Set(['true', 'TRUE', 'false', 'FALSE', 'none', 'NONE'
|
|
|
81
98
|
export class Parser {
|
|
82
99
|
/**
|
|
83
100
|
* @param {ParserEnvironment} environment The parser environment.
|
|
101
|
+
* @param {ParserOptions} [options] Parser options.
|
|
84
102
|
*/
|
|
85
|
-
constructor(environment) {
|
|
103
|
+
constructor(environment, options = {}) {
|
|
86
104
|
/** @type {ParserEnvironment} */
|
|
87
105
|
this.environment = environment;
|
|
106
|
+
/**
|
|
107
|
+
* Whether to capture each node's original content signature while
|
|
108
|
+
* parsing. Signatures back lossless {@link Node#serialize} (they let a
|
|
109
|
+
* node detect whether its subtree was mutated); capturing them hashes
|
|
110
|
+
* the whole tree during parsing, so they are opt-in for consumers that
|
|
111
|
+
* mutate and serialize the AST.
|
|
112
|
+
*
|
|
113
|
+
* @type {boolean}
|
|
114
|
+
*/
|
|
115
|
+
this.captureSignatures = options.captureSignatures === true;
|
|
88
116
|
/** @type {ExpressionParser} */
|
|
89
117
|
this.expressionParser = new ExpressionParser(this);
|
|
90
118
|
/** @type {TokenStream|null} */
|
|
@@ -117,6 +145,8 @@ export class Parser {
|
|
|
117
145
|
this.macroDepth = 0;
|
|
118
146
|
/** @type {number} */
|
|
119
147
|
this.capturingNodeDepth = 0;
|
|
148
|
+
/** @type {number} Current recursion depth of {@link Parser.subparse}. */
|
|
149
|
+
this.subparseDepth = 0;
|
|
120
150
|
/** @type {Array<Node|null>} */
|
|
121
151
|
this.tagStack = [];
|
|
122
152
|
/** @type {string|null} */
|
|
@@ -242,7 +272,9 @@ export class Parser {
|
|
|
242
272
|
module.setAttribute('trailing', stream.getCurrent().leading ?? '');
|
|
243
273
|
|
|
244
274
|
this.correctnessCheck(module);
|
|
245
|
-
captureSignatures
|
|
275
|
+
if (this.captureSignatures) {
|
|
276
|
+
captureSignatures(module);
|
|
277
|
+
}
|
|
246
278
|
|
|
247
279
|
// restore the previous state so an enclosing (outer) parse can resume
|
|
248
280
|
const previous = this.saveStack.pop();
|
|
@@ -275,6 +307,30 @@ export class Parser {
|
|
|
275
307
|
* @returns {Node} The parsed body (single node or a `nodes` list).
|
|
276
308
|
*/
|
|
277
309
|
subparse(test = null, dropNeedle = false) {
|
|
310
|
+
if (this.subparseDepth >= MAX_SUBPARSE_DEPTH) {
|
|
311
|
+
const token = this.getCurrentToken();
|
|
312
|
+
throw new SyntaxError(
|
|
313
|
+
'Template nesting is too deep.',
|
|
314
|
+
token.getLine(),
|
|
315
|
+
this.getStream().getSourceContext(),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
this.subparseDepth += 1;
|
|
319
|
+
try {
|
|
320
|
+
return this.parseUntil(test, dropNeedle);
|
|
321
|
+
} finally {
|
|
322
|
+
this.subparseDepth -= 1;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The unguarded recursive body of {@link subparse}.
|
|
328
|
+
*
|
|
329
|
+
* @param {((token: Token) => boolean)|null} [test]
|
|
330
|
+
* @param {boolean} [dropNeedle]
|
|
331
|
+
* @returns {Node} The parsed body (single node or a `nodes` list).
|
|
332
|
+
*/
|
|
333
|
+
parseUntil(test = null, dropNeedle = false) {
|
|
278
334
|
const stream = this.getStream();
|
|
279
335
|
const lineNumber = this.getCurrentToken().getLine();
|
|
280
336
|
/** @type {Node[]} */
|