@rhinostone/swig-django 2.7.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/lib/parser.js ADDED
@@ -0,0 +1,739 @@
1
+ var ir = require('@rhinostone/swig-core/lib/ir'),
2
+ utils = require('@rhinostone/swig-core/lib/utils'),
3
+ _dangerousProps = require('@rhinostone/swig-core/lib/security').dangerousProps;
4
+
5
+ var lexer = require('./lexer');
6
+ var _t = require('./tokentypes');
7
+
8
+ /**
9
+ * Make a string safe for embedding into a regular expression.
10
+ * @param {string} str
11
+ * @return {string}
12
+ * @private
13
+ */
14
+ function escapeRegExp(str) {
15
+ return str.replace(/[\-\/\\\^$*+?.()|\[\]{}]/g, '\\$&');
16
+ }
17
+
18
+ /**
19
+ * Reserved JS keywords that cannot be used as variable names.
20
+ * @private
21
+ */
22
+ var _reserved = ['break', 'case', 'catch', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'finally', 'for', 'function', 'if', 'in', 'instanceof', 'new', 'return', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', 'while', 'with'];
23
+
24
+ /**
25
+ * Build a VarRef IR node flagged for Django-style runtime resolution. The
26
+ * `resolve` flag makes swig-core's emitVarRef emit `_utils.resolve(_ctx,
27
+ * path)` — dictionary / attribute / method lookup, numeric index access
28
+ * (`{{ list.0 }}`), and auto-call of callable leaves — instead of a static
29
+ * dot-path. Used at every USER-variable site (bare lookups, dotted-path
30
+ * rebuilds in parsePostfix, filter-argument variables). Deliberately NOT
31
+ * used for a function-call callee (emitFnCall reads the callee path directly
32
+ * and ignores the flag) or the synthetic `_utils.slice` reference.
33
+ *
34
+ * @param {string[]} path Path segments.
35
+ * @return {object} IRVarRef with `resolve === true`.
36
+ * @private
37
+ */
38
+ function mkVar(path) {
39
+ var n = ir.varRef(path);
40
+ n.resolve = true;
41
+ return n;
42
+ }
43
+
44
+ /**
45
+ * Django expression parser — Pratt-style recursive descent.
46
+ *
47
+ * Consumes a flat LexerToken[] (produced by swig-django's lexer) and
48
+ * returns an IRExpr tree using swig-core's IR factories. Mirrors the shape
49
+ * of swig-core's TokenParser.parseExpr so the swig-core backend can emit JS
50
+ * from either frontend's output without changes.
51
+ *
52
+ * This is a *lenient superset* of the Django Template Language expression
53
+ * grammar: it keeps the inherited parenthesis / arithmetic / function-call
54
+ * machinery (so every valid Django expression parses, and a handful of
55
+ * non-Django forms like `{{ a + b }}` also happen to work) and layers the
56
+ * Django-specific bits on top:
57
+ *
58
+ * - `|name` and `|name:arg` colon-filters (Django filters take at most one
59
+ * positional argument; the arg is a single literal or variable lookup,
60
+ * never a chained filter or operator expression).
61
+ * - `True` / `False` / `None` literals.
62
+ * - `is` / `is not` as *identity* comparisons (`===` / `!==`), NOT
63
+ * Jinja2-style test calls.
64
+ *
65
+ * Dropped relative to the Jinja2 sibling: `~` concat, `**` power, `//`
66
+ * floor-division, the inline `if`/`else` conditional, and the `is <test>`
67
+ * test-call machinery — none of which exist in Django.
68
+ *
69
+ * CVE-2023-25345 guards (`_dangerousProps`) fire on VAR path segments,
70
+ * DOTKEY matches, STRING-inside-BRACKETOPEN values, FUNCTION/FUNCTIONEMPTY
71
+ * callee names, and filter-argument variable segments — the same
72
+ * checkpoints as the native and Jinja2 frontends.
73
+ *
74
+ * Binding-power table (higher binds tighter):
75
+ *
76
+ * Level | Tokens | Assoc
77
+ * ------+------------------------------------+------
78
+ * 1 | || / or (LOGIC) | left
79
+ * 2 | && / and (LOGIC) | left
80
+ * 3 | == != === !== is "is not" (COMP) | left
81
+ * 4 | < > <= >= in (COMPARATOR) | left
82
+ * 6 | + - (OPERATOR) | left
83
+ * 8 | * / % (OPERATOR) | left
84
+ * post | DOTKEY BRACKETOPEN PARENOPEN | —
85
+ * | FILTEREMPTY (+ optional :arg) |
86
+ * pfx | NOT, unary +/- | —
87
+ *
88
+ * @param {object[]} tokens LexerToken[] from swig-django's lexer.
89
+ * @param {object} [filters] Filter catalog for name validation.
90
+ * Pass `{}` when no catalog is available.
91
+ * @param {object} [_posOut] Optional out-param; final cursor stored on
92
+ * `_posOut.pos` to let callers detect partial
93
+ * consumption.
94
+ * @return {object} IRExpr tree.
95
+ */
96
+ exports.parseExpr = function (tokens, filters, _posOut) {
97
+ var pos = 0;
98
+ filters = filters || {};
99
+
100
+ function skipWS() {
101
+ while (pos < tokens.length && tokens[pos].type === _t.WHITESPACE) { pos += 1; }
102
+ }
103
+ function peek() {
104
+ skipWS();
105
+ return pos < tokens.length ? tokens[pos] : null;
106
+ }
107
+ function consume() {
108
+ var t = peek();
109
+ if (t) { pos += 1; }
110
+ return t;
111
+ }
112
+ function bail(msg) {
113
+ utils.throwError(msg);
114
+ }
115
+
116
+ function guardSegment(segment) {
117
+ if (_dangerousProps.indexOf(segment) !== -1) {
118
+ bail('Unsafe access to "' + segment + '" is not allowed in templates (CVE-2023-25345)');
119
+ }
120
+ }
121
+ function guardBracketString(value) {
122
+ if (_dangerousProps.indexOf(value) !== -1) {
123
+ bail('Unsafe access to "' + value + '" via bracket notation is not allowed in templates (CVE-2023-25345)');
124
+ }
125
+ }
126
+
127
+ function getBinaryOpInfo(tok) {
128
+ var m;
129
+ if (tok.type === _t.LOGIC) {
130
+ if (tok.match === '||') { return { op: '||', prec: 1 }; }
131
+ if (tok.match === '&&') { return { op: '&&', prec: 2 }; }
132
+ }
133
+ if (tok.type === _t.COMPARATOR) {
134
+ m = tok.match;
135
+ if (m === '===' || m === '!==' || m === '==' || m === '!=') {
136
+ return { op: m, prec: 3 };
137
+ }
138
+ return { op: m, prec: 4 };
139
+ }
140
+ // Django `is` / `is not` are *identity* comparisons (not Jinja2 test
141
+ // calls): lowered to `===` / `!==` in parseExpression. Same precedence
142
+ // level as `==` so `x is None` / `x is not None` group naturally.
143
+ if (tok.type === _t.IS) {
144
+ return { op: 'is', prec: 3 };
145
+ }
146
+ if (tok.type === _t.ISNOT) {
147
+ return { op: 'is not', prec: 3 };
148
+ }
149
+ if (tok.type === _t.OPERATOR) {
150
+ m = tok.match;
151
+ if (m === '+' || m === '-') { return { op: m, prec: 6 }; }
152
+ if (m === '*' || m === '/' || m === '%') { return { op: m, prec: 8 }; }
153
+ }
154
+ return null;
155
+ }
156
+
157
+ function unquoteString(match) {
158
+ return match.replace(/^['"]|['"]$/g, '');
159
+ }
160
+
161
+ function parseArgList(closeType) {
162
+ var args = [];
163
+ var first = peek();
164
+ if (first && first.type === closeType) {
165
+ consume();
166
+ return args;
167
+ }
168
+ while (true) {
169
+ args.push(parseExpression(0));
170
+ var next = consume();
171
+ if (!next) { bail('Unexpected end of expression'); }
172
+ if (next.type === closeType) { break; }
173
+ if (next.type !== _t.COMMA) { bail('Expected comma or closing delimiter'); }
174
+ }
175
+ return args;
176
+ }
177
+
178
+ function parseObjectLiteral() {
179
+ var props = [];
180
+ var first = peek();
181
+ if (first && first.type === _t.CURLYCLOSE) {
182
+ consume();
183
+ return ir.objectLiteral([]);
184
+ }
185
+ while (true) {
186
+ var keyTok = consume();
187
+ if (!keyTok) { bail('Unclosed object literal'); }
188
+ var keyExpr;
189
+ if (keyTok.type === _t.STRING) {
190
+ keyExpr = ir.literal('string', unquoteString(keyTok.match));
191
+ } else if (keyTok.type === _t.VAR) {
192
+ if (keyTok.match.indexOf('.') !== -1) {
193
+ bail('Unexpected dot');
194
+ }
195
+ keyExpr = ir.literal('string', keyTok.match);
196
+ } else if (keyTok.type === _t.NUMBER) {
197
+ keyExpr = ir.literal('number', parseFloat(keyTok.match));
198
+ } else {
199
+ bail('Unexpected object key');
200
+ }
201
+ var colon = consume();
202
+ if (!colon || colon.type !== _t.COLON) { bail('Expected colon in object literal'); }
203
+ var value = parseExpression(0);
204
+ props.push(ir.objectProperty(keyExpr, value));
205
+ var next = consume();
206
+ if (!next) { bail('Unclosed object literal'); }
207
+ if (next.type === _t.CURLYCLOSE) { break; }
208
+ if (next.type !== _t.COMMA) { bail('Expected comma or closing curly brace'); }
209
+ }
210
+ return ir.objectLiteral(props);
211
+ }
212
+
213
+ // Django filter argument — a *single* constant or variable lookup, never a
214
+ // chained filter or operator expression. This deliberately does NOT route
215
+ // through parsePostfix (which would greedily consume a trailing `|next`
216
+ // filter and wrongly bind it to the argument instead of the filtered
217
+ // value). An optional leading sign is honored only before a NUMBER, so
218
+ // `|floatformat:-3` works while `|add:-x` is rejected.
219
+ function parseFilterArg() {
220
+ var sign = '';
221
+ var pk = peek();
222
+ if (pk && pk.type === _t.OPERATOR && (pk.match === '-' || pk.match === '+')) {
223
+ consume();
224
+ sign = pk.match;
225
+ }
226
+ var tok = consume();
227
+ if (!tok) { bail('Expected filter argument after ":"'); }
228
+ switch (tok.type) {
229
+ case _t.STRING:
230
+ if (sign) { bail('Unexpected "' + sign + '" before string filter argument'); }
231
+ return ir.literal('string', unquoteString(tok.match));
232
+ case _t.NUMBER:
233
+ return ir.literal('number', parseFloat(sign + tok.match));
234
+ case _t.BOOL:
235
+ if (sign) { bail('Unexpected "' + sign + '" before boolean filter argument'); }
236
+ return ir.literal('bool', tok.match === 'true');
237
+ case _t.NONE:
238
+ if (sign) { bail('Unexpected "' + sign + '" before None filter argument'); }
239
+ return ir.literal('null', null);
240
+ case _t.VAR:
241
+ if (sign) { bail('Unexpected "' + sign + '" before variable filter argument'); }
242
+ var path = tok.match.split('.');
243
+ if (_reserved.indexOf(path[0]) !== -1) {
244
+ bail('Reserved keyword "' + path[0] + '" attempted to be used as a variable');
245
+ }
246
+ utils.each(path, function (segment) {
247
+ guardSegment(segment);
248
+ });
249
+ return mkVar(path);
250
+ }
251
+ bail('Unexpected filter argument "' + tok.match + '"');
252
+ return null;
253
+ }
254
+
255
+ function expectBracketClose() {
256
+ var close = consume();
257
+ if (!close || close.type !== _t.BRACKETCLOSE) {
258
+ bail('Expected closing square bracket');
259
+ }
260
+ }
261
+
262
+ function undefinedLiteral() {
263
+ return ir.literal('undefined', undefined);
264
+ }
265
+
266
+ // Called after the opening `[`. Either a single-key access `[expr]` or a
267
+ // Python-style slice `[start:stop:step]` with any part omitted. A leading
268
+ // COLON (omitted start) or a COLON after the first expression signals a
269
+ // slice, which lowers to `_utils.slice(obj, start, stop, step)` with
270
+ // undefined literals for omitted bounds. A plain `[expr]` lowers to an
271
+ // Access (string keys are CVE-guarded).
272
+ function parseSubscript(obj) {
273
+ var startExpr = null,
274
+ stopExpr = null,
275
+ stepExpr = null,
276
+ isSlice = false,
277
+ pk = peek();
278
+
279
+ if (pk && pk.type === _t.COLON) {
280
+ isSlice = true;
281
+ } else {
282
+ startExpr = parseExpression(0);
283
+ pk = peek();
284
+ if (pk && pk.type === _t.COLON) { isSlice = true; }
285
+ }
286
+
287
+ if (!isSlice) {
288
+ if (startExpr.type === 'Literal' && startExpr.kind === 'string') {
289
+ guardBracketString(startExpr.value);
290
+ }
291
+ expectBracketClose();
292
+ return ir.access(obj, startExpr);
293
+ }
294
+
295
+ consume(); // first colon
296
+ pk = peek();
297
+ if (pk && pk.type !== _t.COLON && pk.type !== _t.BRACKETCLOSE) {
298
+ stopExpr = parseExpression(0);
299
+ }
300
+ pk = peek();
301
+ if (pk && pk.type === _t.COLON) {
302
+ consume(); // second colon
303
+ pk = peek();
304
+ if (pk && pk.type !== _t.BRACKETCLOSE) {
305
+ stepExpr = parseExpression(0);
306
+ }
307
+ }
308
+ expectBracketClose();
309
+ return ir.fnCall(ir.varRef(['_utils', 'slice']), [
310
+ obj,
311
+ startExpr || undefinedLiteral(),
312
+ stopExpr || undefinedLiteral(),
313
+ stepExpr || undefinedLiteral()
314
+ ]);
315
+ }
316
+
317
+ function parsePostfix(expr) {
318
+ var tok, fname, fargs, colon;
319
+ while (true) {
320
+ tok = peek();
321
+ if (!tok) { break; }
322
+ if (tok.type === _t.DOTKEY) {
323
+ consume();
324
+ guardSegment(tok.match);
325
+ if (expr.type === 'VarRef') {
326
+ expr = mkVar(expr.path.concat([tok.match]));
327
+ } else {
328
+ expr = ir.access(expr, ir.literal('string', tok.match));
329
+ }
330
+ } else if (tok.type === _t.BRACKETOPEN) {
331
+ consume();
332
+ expr = parseSubscript(expr);
333
+ } else if (tok.type === _t.PARENOPEN) {
334
+ consume();
335
+ expr = ir.fnCall(expr, parseArgList(_t.PARENCLOSE));
336
+ } else if (tok.type === _t.FILTEREMPTY || tok.type === _t.FILTER) {
337
+ consume();
338
+ fname = tok.match;
339
+ if (filters.hasOwnProperty(fname) && typeof filters[fname] !== 'function') {
340
+ bail('Invalid filter "' + fname + '"');
341
+ }
342
+ // Django colon-filter: an optional single positional argument
343
+ // introduced by a colon (`|date:"Y-m-d"`, `|default:user.name`).
344
+ // `fargs` MUST be reset each iteration — `var` is function-scoped, so
345
+ // without this an arg-bearing filter earlier in the chain would leak
346
+ // its args onto a later no-arg filter (e.g. `x|default:y|upper`).
347
+ fargs = undefined;
348
+ colon = peek();
349
+ if (colon && colon.type === _t.COLON) {
350
+ consume();
351
+ fargs = [parseFilterArg()];
352
+ }
353
+ expr = ir.filterCallExpr(fname, expr, fargs);
354
+ } else {
355
+ break;
356
+ }
357
+ }
358
+ return expr;
359
+ }
360
+
361
+ function parsePrimary() {
362
+ var tok = peek();
363
+ if (!tok) { bail('Unexpected end of expression'); }
364
+
365
+ tok = consume();
366
+ var m;
367
+ switch (tok.type) {
368
+ case _t.STRING:
369
+ return parsePostfix(ir.literal('string', unquoteString(tok.match)));
370
+ case _t.NUMBER:
371
+ return parsePostfix(ir.literal('number', parseFloat(tok.match)));
372
+ case _t.BOOL:
373
+ return parsePostfix(ir.literal('bool', tok.match === 'true'));
374
+ case _t.NONE:
375
+ return parsePostfix(ir.literal('null', null));
376
+ case _t.PARENOPEN:
377
+ var grouped = parseExpression(0);
378
+ var close = consume();
379
+ if (!close || close.type !== _t.PARENCLOSE) {
380
+ bail('Mismatched nesting state');
381
+ }
382
+ return parsePostfix(grouped);
383
+ case _t.BRACKETOPEN:
384
+ return parsePostfix(ir.arrayLiteral(parseArgList(_t.BRACKETCLOSE)));
385
+ case _t.CURLYOPEN:
386
+ return parsePostfix(parseObjectLiteral());
387
+ case _t.VAR:
388
+ var path = tok.match.split('.');
389
+ if (_reserved.indexOf(path[0]) !== -1) {
390
+ bail('Reserved keyword "' + path[0] + '" attempted to be used as a variable');
391
+ }
392
+ utils.each(path, function (segment) {
393
+ guardSegment(segment);
394
+ });
395
+ return parsePostfix(mkVar(path));
396
+ case _t.FUNCTION:
397
+ case _t.FUNCTIONEMPTY:
398
+ m = tok.match;
399
+ if (_reserved.indexOf(m) !== -1) {
400
+ bail('Reserved keyword "' + m + '" attempted to be used as a variable');
401
+ }
402
+ guardSegment(m);
403
+ if (tok.type === _t.FUNCTIONEMPTY) {
404
+ return parsePostfix(ir.fnCall(ir.varRef([m]), []));
405
+ }
406
+ return parsePostfix(ir.fnCall(ir.varRef([m]), parseArgList(_t.PARENCLOSE)));
407
+ }
408
+ bail('Unexpected token "' + tok.match + '"');
409
+ return null;
410
+ }
411
+
412
+ function parseUnary() {
413
+ var tok = peek();
414
+ if (tok && tok.type === _t.NOT) {
415
+ consume();
416
+ return ir.unaryOp('!', parseUnary());
417
+ }
418
+ if (tok && tok.type === _t.OPERATOR && (tok.match === '+' || tok.match === '-')) {
419
+ consume();
420
+ return ir.unaryOp(tok.match, parseUnary());
421
+ }
422
+ return parsePrimary();
423
+ }
424
+
425
+ function parseExpression(minPrec) {
426
+ var left = parseUnary();
427
+ while (true) {
428
+ var tok = peek();
429
+ if (!tok) { break; }
430
+ var info = getBinaryOpInfo(tok);
431
+ if (!info || info.prec < minPrec) { break; }
432
+ consume();
433
+ // Django `is` / `is not` — identity comparison. The RHS is a full
434
+ // sub-expression (usually a `None` / `True` / `False` literal or a
435
+ // variable), and the operator lowers to `===` / `!==`.
436
+ if (info.op === 'is' || info.op === 'is not') {
437
+ var rightId = parseExpression(info.prec + 1);
438
+ // `x is None` / `x is not None` on a VarRef subject: emitVarRef
439
+ // coerces a missing or null lookup to "" for safe interpolation,
440
+ // which loses the null signal a plain `=== null` needs. Route through
441
+ // IRVarRefExists (defined-and-non-null) instead — the same handling
442
+ // the Jinja2 sibling uses for its `is none` test. `is None` is true
443
+ // when the subject is absent or null (`!exists`); `is not None` is
444
+ // true when it is present (`exists`). `is True` / `is False` /
445
+ // numeric / variable RHS are unaffected — those values are never
446
+ // null, so emitVarRef returns them intact and plain `===` is correct.
447
+ if (rightId.type === 'Literal' && rightId.kind === 'null' && left.type === 'VarRef') {
448
+ var exists = ir.varRefExists(left.path, left.loc);
449
+ left = info.op === 'is' ? ir.unaryOp('!', exists) : exists;
450
+ continue;
451
+ }
452
+ left = ir.binaryOp(info.op === 'is' ? '===' : '!==', left, rightId);
453
+ continue;
454
+ }
455
+ var right = parseExpression(info.prec + 1);
456
+ left = ir.binaryOp(info.op, left, right);
457
+ }
458
+ return left;
459
+ }
460
+
461
+ var result = parseExpression(0);
462
+
463
+ if (_posOut) {
464
+ _posOut.pos = pos;
465
+ } else {
466
+ skipWS();
467
+ if (pos < tokens.length) {
468
+ bail('Unexpected token "' + tokens[pos].match + '"');
469
+ }
470
+ }
471
+
472
+ return result;
473
+ };
474
+
475
+
476
+ /**
477
+ * Parse a Django source string into a parse tree of pre-built IR nodes and
478
+ * tag tokens, ready for swig-core's backend walker.
479
+ *
480
+ * Mirrors the shape of the native swig `parser.parse` so the same
481
+ * `engine.install(self, frontend)` plumbing works for every frontend:
482
+ *
483
+ * - Plain text chunks → `IRText` nodes (spliced through by the backend).
484
+ * - `{{ … }}` chunks → `IROutput` nodes built via parseExpr; if
485
+ * autoescape is on, the IROutput.filters slot carries an `e` filterCall
486
+ * tail unless one of the chained filters is `.safe`.
487
+ * - `{% … %}` chunks → TagToken from the registered tag's `parse`.
488
+ * - `{# … #}` chunks → dropped.
489
+ *
490
+ * Django has no whitespace-control markers (`{{-` / `-%}` are not DTL
491
+ * syntax), so the splitter does not strip surrounding whitespace. The
492
+ * verbatim-text tag is `{% verbatim %}` (Django's analog of swig's `raw`);
493
+ * its body is copied through untouched.
494
+ *
495
+ * Django tags own their argument parsing directly via `parseExpr`; there is
496
+ * no `parser.on(types.X, fn)` callback model. The `parser` argument passed
497
+ * to a tag's `parse(str, line, parser, _t, stack, opts, swig, token)` is
498
+ * this module itself (`exports`), which exposes `parseExpr` and `lexer`.
499
+ *
500
+ * @param {object} swig The Swig instance (or undefined when called
501
+ * outside an engine context).
502
+ * @param {string} source Django template source.
503
+ * @param {object} opts Per-call options. Honors `varControls`,
504
+ * `tagControls`, `cmtControls`, `autoescape`,
505
+ * `filename`.
506
+ * @param {object} tags Tag registry (`{ name: { parse, compile, ends,
507
+ * block } }`).
508
+ * @param {object} filters Filter catalog. Only used for `.safe` lookup at
509
+ * autoescape time.
510
+ * @return {object} `{ name, parent, parentExpr, tokens, blocks }` tree
511
+ * consumed by `engine.compile`.
512
+ * @throws {Error} On unknown tag, mismatched end tag, or any parseExpr
513
+ * error inside a `{{ … }}` chunk.
514
+ */
515
+ exports.parse = function (swig, source, opts, tags, filters) {
516
+ source = String(source).replace(/\r\n/g, '\n');
517
+ opts = opts || {};
518
+ tags = tags || {};
519
+ filters = filters || {};
520
+
521
+ var varControls = opts.varControls || ['{{', '}}'];
522
+ var tagControls = opts.tagControls || ['{%', '%}'];
523
+ var cmtControls = opts.cmtControls || ['{#', '#}'];
524
+
525
+ var escape = opts.autoescape;
526
+ if (typeof escape === 'undefined') { escape = true; }
527
+ // Region-scoped autoescape. `escape` is the template-level default;
528
+ // `{% autoescape on/off %}` pushes a new value for the duration of its
529
+ // body and `{% endautoescape %}` pops it, mirroring the inRaw flag below.
530
+ // parseVariable reads the current top, so the `e` filter tail is baked
531
+ // per-region at parse time (the backend IRAutoescape node is inert).
532
+ var escapeStack = [escape];
533
+
534
+ var tagOpen = tagControls[0];
535
+ var tagClose = tagControls[1];
536
+ var varOpen = varControls[0];
537
+ var varClose = varControls[1];
538
+ var cmtOpen = cmtControls[0];
539
+ var cmtClose = cmtControls[1];
540
+
541
+ var anyChar = '[\\s\\S]*?';
542
+ var splitter = new RegExp(
543
+ '(' +
544
+ escapeRegExp(tagOpen) + anyChar + escapeRegExp(tagClose) + '|' +
545
+ escapeRegExp(varOpen) + anyChar + escapeRegExp(varClose) + '|' +
546
+ escapeRegExp(cmtOpen) + anyChar + escapeRegExp(cmtClose) +
547
+ ')'
548
+ );
549
+ // No whitespace-control: Django has no `{{-` / `-%}` markers, so the strip
550
+ // patterns carry no `-?` and there is no strip-before / strip-after
551
+ // bookkeeping.
552
+ var tagStrip = new RegExp('^' + escapeRegExp(tagOpen) + '\\s*|\\s*' + escapeRegExp(tagClose) + '$', 'g');
553
+ var varStrip = new RegExp('^' + escapeRegExp(varOpen) + '\\s*|\\s*' + escapeRegExp(varClose) + '$', 'g');
554
+
555
+ var line = 1;
556
+ var stack = [];
557
+ var parent = null;
558
+ var parentExpr = null;
559
+ var tokens = [];
560
+ var blocks = {};
561
+ var inRaw = false;
562
+
563
+ /**
564
+ * Build an IROutput node for a `{{ … }}` chunk. The autoescape `e` filter
565
+ * tail is appended unless a `.safe` filter appears in the chain.
566
+ *
567
+ * @param {string} str Inner expression text (controls already stripped).
568
+ * @param {number} _line Source line of the opening control.
569
+ * @return {object} IROutput IR node.
570
+ * @private
571
+ */
572
+ function parseVariable(str, _line) {
573
+ var lexed = lexer.read(utils.strip(str));
574
+ var sawSafe = false;
575
+ utils.each(lexed, function (tok) {
576
+ if (tok.type === _t.FILTEREMPTY || tok.type === _t.FILTER) {
577
+ if (filters.hasOwnProperty(tok.match) && filters[tok.match].safe === true) {
578
+ sawSafe = true;
579
+ }
580
+ }
581
+ });
582
+ var expr = exports.parseExpr(lexed, filters);
583
+ var tail;
584
+ var esc = escapeStack[escapeStack.length - 1];
585
+ if (esc && !sawSafe) {
586
+ var escapeArgs;
587
+ if (typeof esc === 'string') {
588
+ escapeArgs = [ir.literal('string', esc)];
589
+ }
590
+ tail = [ir.filterCall('e', escapeArgs)];
591
+ }
592
+ var node = ir.output(expr, tail);
593
+ // Coerce null / undefined to "" for any non-VarRef output (function
594
+ // calls, dynamic bracket access, ...). A *plain* VarRef already coerces
595
+ // inside emitVarRef, so the common `{{ name }}` path stays wrapper-free —
596
+ // but a *resolve* VarRef (`{{ obj.method }}`, `{{ list.0 }}`) returns the
597
+ // raw resolved value (including null) so filters such as default_if_none
598
+ // see a real null; that raw null reaches the output drain unescaped (the
599
+ // autoescape `e` tail passes null through), so it needs coerceOutput here.
600
+ if (expr.type !== 'VarRef' || expr.resolve) {
601
+ node.coerce = true;
602
+ }
603
+ return node;
604
+ }
605
+
606
+ /**
607
+ * Dispatch a `{% … %}` chunk to its registered tag. Handles `end<name>`
608
+ * close-tag matching against the open-tag stack (filename-aware throws are
609
+ * routed via utils.throwError so the frontend can wrap them via
610
+ * onCompileError).
611
+ *
612
+ * @param {string} str Inner tag text (controls already stripped).
613
+ * @param {number} _line Source line of the opening control.
614
+ * @return {?object} TagToken, or undefined for end-tag close.
615
+ * @private
616
+ */
617
+ function parseTag(str, _line) {
618
+ var chunks = str.split(/\s+(.+)?/);
619
+ var tagName = chunks.shift();
620
+ var tagArgs = chunks[0] || '';
621
+ var last;
622
+
623
+ if (tagName.indexOf('end') === 0) {
624
+ var openName = tagName.replace(/^end/, '');
625
+ last = stack[stack.length - 1];
626
+ if (last && last.name === openName && last.ends) {
627
+ if (openName === 'verbatim' || openName === 'comment') { inRaw = false; }
628
+ if (openName === 'autoescape') { escapeStack.pop(); }
629
+ stack.pop();
630
+ return;
631
+ }
632
+ if (!inRaw) {
633
+ utils.throwError('Unexpected end of tag "' + openName + '"', _line, opts.filename);
634
+ }
635
+ }
636
+
637
+ // Inside a verbatim block, non-matching tag chunks fall through to the
638
+ // splitter's chunk-as-text path. The `endverbatim` close has already
639
+ // been handled above; everything else returns undefined so the splitter
640
+ // wraps the verbatim chunk via `ir.text`.
641
+ if (inRaw) {
642
+ return;
643
+ }
644
+
645
+ if (!tags.hasOwnProperty(tagName)) {
646
+ utils.throwError('Unexpected tag "' + tagName + '"', _line, opts.filename);
647
+ }
648
+
649
+ var tag = tags[tagName];
650
+ var token = {
651
+ block: !!tag.block,
652
+ compile: tag.compile,
653
+ args: [],
654
+ content: [],
655
+ ends: !!tag.ends,
656
+ name: tagName,
657
+ irExpr: undefined,
658
+ // Region autoescape state at this tag's position (the same value the
659
+ // `{{ … }}` output path reads). Output-producing tags (firstof, cycle)
660
+ // consult it to escape their output exactly as a `{{ … }}` would —
661
+ // honoring an enclosing `{% autoescape off %}`. Other tags ignore it.
662
+ escape: escapeStack[escapeStack.length - 1]
663
+ };
664
+
665
+ var ok = tag.parse(tagArgs, _line, exports, _t, stack, opts, swig, token);
666
+ if (!ok) {
667
+ utils.throwError('Unexpected tag "' + tagName + '"', _line, opts.filename);
668
+ }
669
+
670
+ if (tagName === 'verbatim' || tagName === 'comment') {
671
+ // Both bypass parsing of their body: verbatim renders it verbatim,
672
+ // comment discards it (its compile drops the captured text).
673
+ inRaw = true;
674
+ }
675
+ if (tagName === 'autoescape') {
676
+ escapeStack.push(token.escapeValue);
677
+ }
678
+
679
+ return token;
680
+ }
681
+
682
+ utils.each(source.split(splitter), function (chunk) {
683
+ var token, lines;
684
+
685
+ if (!chunk) { return; }
686
+
687
+ if (!inRaw && utils.startsWith(chunk, varOpen) && utils.endsWith(chunk, varClose)) {
688
+ token = parseVariable(chunk.replace(varStrip, ''), line);
689
+ } else if (utils.startsWith(chunk, tagOpen) && utils.endsWith(chunk, tagClose)) {
690
+ token = parseTag(chunk.replace(tagStrip, ''), line);
691
+ if (token) {
692
+ if (token.name === 'extends') {
693
+ parent = token.args.length ? String(token.args[0]) : null;
694
+ parentExpr = token.irExpr && token.irExpr.file;
695
+ } else if (token.block && !stack.length) {
696
+ blocks[token.args.join('')] = token;
697
+ }
698
+ }
699
+ // parseTag returns undefined for non-`endverbatim` tag chunks while
700
+ // inRaw is true. Wrap the original chunk as literal text so the
701
+ // content inside `{% verbatim %}` renders verbatim.
702
+ if (inRaw && !token) {
703
+ token = ir.text(chunk);
704
+ }
705
+ } else if (!inRaw && utils.startsWith(chunk, cmtOpen) && utils.endsWith(chunk, cmtClose)) {
706
+ lines = chunk.match(/\n/g);
707
+ line += lines ? lines.length : 0;
708
+ return;
709
+ } else {
710
+ token = ir.text(chunk);
711
+ }
712
+
713
+ if (token) {
714
+ if (stack.length) {
715
+ stack[stack.length - 1].content.push(token);
716
+ } else {
717
+ tokens.push(token);
718
+ }
719
+ if (token.name && token.ends) {
720
+ stack.push(token);
721
+ }
722
+ }
723
+
724
+ lines = chunk.match(/\n/g);
725
+ line += lines ? lines.length : 0;
726
+ });
727
+
728
+ if (stack.length) {
729
+ utils.throwError('Missing end tag for "' + stack[stack.length - 1].name + '"', line, opts.filename);
730
+ }
731
+
732
+ return {
733
+ name: opts.filename,
734
+ parent: parent,
735
+ parentExpr: parentExpr,
736
+ tokens: tokens,
737
+ blocks: blocks
738
+ };
739
+ };