@mrhenry/twig-tokenizer 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/lexer.js ADDED
@@ -0,0 +1,1766 @@
1
+ // @ts-check
2
+ /**
3
+ * The {@link Lexer} and {@link Source} classes.
4
+ *
5
+ * Mirrors `src/Lexer.php` and `src/Source.php` of the reference
6
+ * implementation. See `spec/02-lexical-structure.md`.
7
+ *
8
+ * @module twig-tokenizer
9
+ */
10
+ import { Token, TokenType } from './token.js';
11
+ import { TokenStream } from './token-stream.js';
12
+ import { SyntaxError } from './errors.js';
13
+ import { OPERATORS } from './operators.js';
14
+ import {
15
+ APOSTROPHE,
16
+ CARRIAGE_RETURN,
17
+ FULL_STOP,
18
+ HYPHEN_MINUS,
19
+ LATIN_CAPITAL_LETTER_E,
20
+ LATIN_SMALL_LETTER_E,
21
+ LEFT_CURLY_BRACKET,
22
+ LINE_FEED,
23
+ LOW_LINE,
24
+ NUMBER_SIGN,
25
+ PLUS_SIGN,
26
+ QUOTATION_MARK,
27
+ REVERSE_SOLIDUS,
28
+ RIGHT_CURLY_BRACKET,
29
+ VERTICAL_LINE,
30
+ } from './code-points/code-points.js';
31
+ import {
32
+ isAsciiLetterCodePoint,
33
+ isDigitCodePoint,
34
+ isHexDigitCodePoint,
35
+ isNameCodePoint,
36
+ isNameStartCodePoint,
37
+ isOctalDigitCodePoint,
38
+ isOperatorDelimiterCodePoint,
39
+ isPhpTrimCodePoint,
40
+ isWhitespaceCodePoint,
41
+ } from './code-points/ranges.js';
42
+
43
+ /**
44
+ * Holds information about a non-compiled Twig template.
45
+ */
46
+ export class Source {
47
+ /**
48
+ * @param {string} code The template source code.
49
+ * @param {string} name The template logical name.
50
+ * @param {string} [path] The filesystem path of the template, if any.
51
+ */
52
+ constructor(code, name, path = '') {
53
+ /** @type {string} */
54
+ this.code = code;
55
+ /** @type {string} */
56
+ this.name = name;
57
+ /** @type {string} */
58
+ this.path = path;
59
+ }
60
+
61
+ /**
62
+ * @returns {string} The source code.
63
+ */
64
+ getCode() {
65
+ return this.code;
66
+ }
67
+
68
+ /**
69
+ * @returns {string} The template name.
70
+ */
71
+ getName() {
72
+ return this.name;
73
+ }
74
+
75
+ /**
76
+ * @returns {string} The template path.
77
+ */
78
+ getPath() {
79
+ return this.path;
80
+ }
81
+
82
+ /**
83
+ * Returns the 1-based column for a 0-based offset in the source code.
84
+ *
85
+ * Mirrors the byte-oriented `Source::getColumn()` of the reference
86
+ * implementation: the column counts UTF-8 bytes since the last newline.
87
+ *
88
+ * @param {number} offset A negative offset means the position is unknown.
89
+ * @returns {number|null} The 1-based column, or null.
90
+ */
91
+ getColumn(offset) {
92
+ if (offset < 0) {
93
+ return null;
94
+ }
95
+ const before = this.code.slice(0, offset).replace(/\r\n|\r/g, '\n');
96
+ const lineStart = before.lastIndexOf('\n');
97
+ const prefix = lineStart === -1 ? before : before.slice(lineStart + 1);
98
+ return new TextEncoder().encode(prefix).length + 1;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Lexer options.
104
+ *
105
+ * @typedef {object} LexerOptions
106
+ * @property {string[]} [tag_comment]
107
+ * @property {string[]} [tag_block]
108
+ * @property {string[]} [tag_variable]
109
+ * @property {string} [whitespace_trim]
110
+ * @property {string} [whitespace_line_trim]
111
+ * @property {string} [whitespace_line_chars]
112
+ * @property {string[]} [interpolation]
113
+ * @property {string} [punctuation]
114
+ */
115
+
116
+ /**
117
+ * The default lexer configuration, mirroring `Twig\Lexer`'s constructor
118
+ * defaults (see `spec/01-introduction.md` §1.2 and `spec/02-lexical-structure.md`).
119
+ *
120
+ * Exposed so tooling (grammar/config conformance tests) can read the canonical
121
+ * delimiters, whitespace-control markers and interpolation markers.
122
+ *
123
+ */
124
+ export const DEFAULT_LEXER_OPTIONS = Object.freeze({
125
+ tag_comment: ['{#', '#}'],
126
+ tag_block: ['{%', '%}'],
127
+ tag_variable: ['{{', '}}'],
128
+ whitespace_trim: '-',
129
+ whitespace_line_trim: '~',
130
+ whitespace_line_chars: ' \t\0\x0B',
131
+ interpolation: ['#{', '}'],
132
+ punctuation: '',
133
+ });
134
+
135
+ /**
136
+ * Lexer state constants.
137
+ *
138
+ * @readonly
139
+ * @enum {number}
140
+ */
141
+ export const LexerState = {
142
+ DATA: 0,
143
+ BLOCK: 1,
144
+ VARIABLE: 2,
145
+ STRING: 3,
146
+ INTERPOLATION: 4,
147
+ };
148
+
149
+ /** The word `verbatim`, as a literal string. */
150
+ const VERBATIM = 'verbatim';
151
+
152
+ /** The word `endverbatim`, as a literal string. */
153
+ const ENDVERBATIM = 'endverbatim';
154
+
155
+ /** The word `line`, as a literal string. */
156
+ const LINE = 'line';
157
+
158
+ /**
159
+ * Operators, matched longest-first like the reference implementation's
160
+ * operator regular expression (which sorts its alternatives by length).
161
+ * These tables are built once at module load, never per lexer instance.
162
+ */
163
+ const OPERATORS_SORTED = [...OPERATORS].sort((a, b) => b.length - a.length);
164
+
165
+ /** @type {Map<number, string[]>} Operators grouped by their first code unit. */
166
+ const OPERATORS_BY_FIRST_CHAR = new Map();
167
+ for (const operator of OPERATORS_SORTED) {
168
+ const first = operator.charCodeAt(0);
169
+ let list = OPERATORS_BY_FIRST_CHAR.get(first);
170
+ if (list === undefined) {
171
+ list = [];
172
+ OPERATORS_BY_FIRST_CHAR.set(first, list);
173
+ }
174
+ list.push(operator);
175
+ }
176
+
177
+ /** The few operators containing whitespace must use the whitespace-aware matcher. */
178
+ const OPERATORS_WITH_WHITESPACE = new Set(
179
+ OPERATORS_SORTED.filter((operator) => operator.includes(' ')),
180
+ );
181
+
182
+ /** @type {string[]} */
183
+ const OPENING_BRACKETS = ['{', '(', '['];
184
+ /** @type {string[]} */
185
+ const CLOSING_BRACKETS = ['}', ')', ']'];
186
+
187
+ /** Default punctuation characters. */
188
+ const PUNCTUATION = '()[]{}?:.,|';
189
+
190
+ /**
191
+ * Whether a code unit is one of the default punctuation characters.
192
+ *
193
+ * @param {number} c The code unit to test.
194
+ * @returns {boolean}
195
+ */
196
+ function isDefaultPunctuation(c) {
197
+ return PUNCTUATION.includes(String.fromCharCode(c));
198
+ }
199
+
200
+ /**
201
+ * Whether a code unit is one of the line-trim characters.
202
+ *
203
+ * Mirrors `whitespace_line_chars` (`" \t\0\x0B"` by default), which is used
204
+ * after a `~` trim marker and by `rtrim($text, " \t\0\x0B")`.
205
+ *
206
+ * @param {number} search The code unit to test.
207
+ * @param {string} chars The configured line-trim characters.
208
+ * @returns {boolean}
209
+ */
210
+ function isLineTrimCodeUnit(search, chars) {
211
+ return chars.includes(String.fromCharCode(search));
212
+ }
213
+
214
+ /**
215
+ * Matches an operator literal at `cursor`, allowing whitespace inside the
216
+ * operator to match one or more whitespace code points.
217
+ *
218
+ * @param {string} code The template source.
219
+ * @param {number} cursor The offset to match at.
220
+ * @param {string} operator The operator literal.
221
+ * @returns {number} The offset just past the match, or -1.
222
+ */
223
+ function matchOperatorText(code, cursor, operator) {
224
+ let i = cursor;
225
+ let j = 0;
226
+ const len = operator.length;
227
+
228
+ while (j < len) {
229
+ const c = operator.charCodeAt(j);
230
+ if (isWhitespaceCodePoint(c)) {
231
+ if (i >= code.length || !isWhitespaceCodePoint(code.charCodeAt(i))) {
232
+ return -1;
233
+ }
234
+ while (i < code.length && isWhitespaceCodePoint(code.charCodeAt(i))) {
235
+ i += 1;
236
+ }
237
+ j += 1;
238
+ } else {
239
+ if (code.charCodeAt(i) !== c) {
240
+ return -1;
241
+ }
242
+ i += 1;
243
+ j += 1;
244
+ }
245
+ }
246
+
247
+ return i;
248
+ }
249
+
250
+ /**
251
+ * Whether the two code points immediately before `cursor` look like a dot or
252
+ * pipe preceding the current position.
253
+ *
254
+ * Mirrors the `(?<![.|][\s]|.[.|])` lookbehind of the reference
255
+ * implementation.
256
+ *
257
+ * @param {string} code The template source.
258
+ * @param {number} cursor The offset of the operator start.
259
+ * @returns {boolean}
260
+ */
261
+ function hasDotOrPipeBefore(code, cursor) {
262
+ if (cursor < 2) {
263
+ return false;
264
+ }
265
+ const a = code.charCodeAt(cursor - 2);
266
+ const b = code.charCodeAt(cursor - 1);
267
+ const aIsDotOrPipe = a === FULL_STOP || a === VERTICAL_LINE;
268
+ const bIsDotOrPipe = b === FULL_STOP || b === VERTICAL_LINE;
269
+ if (aIsDotOrPipe && isWhitespaceCodePoint(b)) {
270
+ return true;
271
+ }
272
+ return bIsDotOrPipe;
273
+ }
274
+
275
+ /**
276
+ * Trims trailing code units from `value` that satisfy `predicate`.
277
+ *
278
+ * @param {string} value
279
+ * @param {(search: number) => boolean} predicate
280
+ * @returns {string}
281
+ */
282
+ function trimEnd(value, predicate) {
283
+ let end = value.length;
284
+ while (end > 0 && predicate(value.charCodeAt(end - 1))) {
285
+ end -= 1;
286
+ }
287
+ return value.slice(0, end);
288
+ }
289
+
290
+ /**
291
+ * PHP `trim()` with the default character list (`" \t\n\r\v\x00"`).
292
+ *
293
+ * PHP's default trim does *not* remove the form feed (`\f`); JS `String#trim()`
294
+ * does. Keeping the byte-identical behavior matters for documentation-comment
295
+ * attachment and trimming.
296
+ *
297
+ * @param {string} value
298
+ * @returns {string}
299
+ */
300
+ function trimLikePhp(value) {
301
+ let start = 0;
302
+ let end = value.length;
303
+ while (start < end && isPhpTrimCodePoint(value.charCodeAt(start))) {
304
+ start += 1;
305
+ }
306
+ while (end > start && isPhpTrimCodePoint(value.charCodeAt(end - 1))) {
307
+ end -= 1;
308
+ }
309
+ return value.slice(start, end);
310
+ }
311
+
312
+ /**
313
+ * Normalizes `\r\n` and `\r` to `\n`.
314
+ *
315
+ * @param {string} text
316
+ * @returns {string}
317
+ */
318
+ function normalizeNewlines(text) {
319
+ if (!text.includes('\r')) {
320
+ return text;
321
+ }
322
+ let result = '';
323
+ let i = 0;
324
+ const len = text.length;
325
+ while (i < len) {
326
+ const c = text.charCodeAt(i);
327
+ if (c === CARRIAGE_RETURN) {
328
+ if (text.charCodeAt(i + 1) === LINE_FEED) {
329
+ i += 1;
330
+ }
331
+ result += '\n';
332
+ } else {
333
+ result += text[i];
334
+ }
335
+ i += 1;
336
+ }
337
+ return result;
338
+ }
339
+
340
+ /**
341
+ * Processes escape sequences in a string fragment (PHP `stripcslashes`).
342
+ *
343
+ * @param {string} string The raw string content.
344
+ * @param {string} _quoteType The string delimiter (`'` or `"`).
345
+ * @returns {string} The unescaped string.
346
+ */
347
+ function stripcslashes(string, _quoteType) {
348
+ let result = '';
349
+ let i = 0;
350
+ const length = string.length;
351
+
352
+ while (i < length) {
353
+ const position = string.indexOf('\\', i);
354
+ if (position === -1) {
355
+ result += string.slice(i);
356
+ break;
357
+ }
358
+ result += string.slice(i, position);
359
+ i = position + 1;
360
+
361
+ if (i >= length) {
362
+ result += '\\';
363
+ break;
364
+ }
365
+
366
+ const nextChar = string[i];
367
+ if (nextChar === 'f') {
368
+ result += '\f';
369
+ } else if (nextChar === 'n') {
370
+ result += '\n';
371
+ } else if (nextChar === 'r') {
372
+ result += '\r';
373
+ } else if (nextChar === 't') {
374
+ result += '\t';
375
+ } else if (nextChar === 'v') {
376
+ result += '\v';
377
+ } else if (nextChar === '\\') {
378
+ result += nextChar;
379
+ } else if (nextChar === "'" || nextChar === '"') {
380
+ result += nextChar;
381
+ } else if (nextChar === '#' && i + 1 < length && string[i + 1] === '{') {
382
+ result += '#{';
383
+ i += 1;
384
+ } else if (nextChar === 'x' && i + 1 < length && isHexDigitCodePoint(string.charCodeAt(i + 1))) {
385
+ let hexadecimal = string[i + 1];
386
+ i += 1;
387
+ if (i + 1 < length && isHexDigitCodePoint(string.charCodeAt(i + 1))) {
388
+ hexadecimal += string[i + 1];
389
+ i += 1;
390
+ }
391
+ result += String.fromCharCode(parseInt(hexadecimal, 16));
392
+ } else if (isOctalDigitCodePoint(nextChar.charCodeAt(0))) {
393
+ let octal = nextChar;
394
+ while (i + 1 < length && isOctalDigitCodePoint(string.charCodeAt(i + 1)) && octal.length < 3) {
395
+ octal += string[i + 1];
396
+ i += 1;
397
+ }
398
+ result += String.fromCharCode(parseInt(octal, 8) % 256);
399
+ } else {
400
+ result += nextChar;
401
+ }
402
+
403
+ i += 1;
404
+ }
405
+
406
+ return result;
407
+ }
408
+
409
+ /**
410
+ * Reads a numeric literal from `code[start, end)`, skipping underscores, and
411
+ * converts it to a number.
412
+ *
413
+ * Mirrors `0 + str_replace('_', '', $match[0])` of the reference
414
+ * implementation.
415
+ *
416
+ * @param {string} code The template source.
417
+ * @param {number} start The literal start.
418
+ * @param {number} end The literal end.
419
+ * @returns {number}
420
+ */
421
+ function numberValue(code, start, end) {
422
+ let result = '';
423
+ for (let i = start; i < end; i += 1) {
424
+ const c = code.charCodeAt(i);
425
+ if (c !== LOW_LINE) {
426
+ result += code[i];
427
+ }
428
+ }
429
+ return Number(result);
430
+ }
431
+
432
+ /**
433
+ * Consumes zero or more `_` + digit groups starting at `i`.
434
+ *
435
+ * @param {string} code The template source.
436
+ * @param {number} i The offset to start from.
437
+ * @param {number} len The code length.
438
+ * @returns {number} The offset after the consumed groups.
439
+ */
440
+ function consumeUnderscoreGroups(code, i, len) {
441
+ while (
442
+ i + 1 < len &&
443
+ code.charCodeAt(i) === LOW_LINE &&
444
+ isDigitCodePoint(code.charCodeAt(i + 1))
445
+ ) {
446
+ i += 2;
447
+ while (i < len && isDigitCodePoint(code.charCodeAt(i))) {
448
+ i += 1;
449
+ }
450
+ }
451
+ return i;
452
+ }
453
+
454
+ /**
455
+ * The Twig lexer: tokenizes template source into a {@link TokenStream}.
456
+ *
457
+ * Operates in states: data (static text), block (`{%`), variable (`{{`),
458
+ * string, and interpolation. Lexing is a single forward pass over the code
459
+ * points of the source; no intermediate token-start table is built.
460
+ */
461
+ export class Lexer {
462
+ /**
463
+ * @param {LexerOptions} [options] Lexer options.
464
+ */
465
+ constructor(options = {}) {
466
+ /** @type {LexerOptions & {tag_comment: string[], tag_block: string[], tag_variable: string[], whitespace_trim: string, whitespace_line_trim: string, whitespace_line_chars: string, interpolation: string[]}} */
467
+ this.options = {
468
+ ...DEFAULT_LEXER_OPTIONS,
469
+ ...options,
470
+ };
471
+
472
+ const o = this.options;
473
+
474
+ // delimiters: distinct first characters of every tag/comment start; when
475
+ // they all share a single character, the data scan can jump straight to
476
+ // its next occurrence instead of peeking at every code unit
477
+ const candidates = [
478
+ o.tag_variable[0],
479
+ o.tag_block[0],
480
+ o.tag_comment[0] + '#',
481
+ o.tag_comment[0],
482
+ ];
483
+ const firstChars = [...new Set(candidates.map((c) => c[0]))];
484
+ /** @type {string} */
485
+ this.delimiterFirstChars = firstChars.join('');
486
+ /** @type {string|null} */
487
+ this.delimiterFirstChar = firstChars.length === 1 ? firstChars[0] : null;
488
+
489
+ /** The documentation-comment start marker (`{#` + `#`). */
490
+ this.documentationStart = o.tag_comment[0] + '#';
491
+
492
+ /** Keeps the empty comment (`{##}`) lexing as a regular comment. */
493
+ this.emptyCommentLookahead = o.tag_comment[1].startsWith('#')
494
+ ? o.tag_comment[1].slice(1)
495
+ : null;
496
+
497
+ // comment closing markers, precomputed once: `[marker, kind]` where kind
498
+ // is 0 (followed by whitespace), 1 (followed by line-trim chars) or
499
+ // 2 (plain, optionally followed by a newline)
500
+ /** @type {Array<[string, number]>} */
501
+ this.commentMarkers = [
502
+ [o.whitespace_trim + o.tag_comment[1], 0],
503
+ [o.whitespace_line_trim + o.tag_comment[1], 1],
504
+ [o.tag_comment[1], 2],
505
+ ];
506
+ /** @type {Array<[string, number]>} */
507
+ this.docCommentMarkers = [
508
+ [o.whitespace_trim + '#' + o.tag_comment[1], 0],
509
+ [o.whitespace_line_trim + '#' + o.tag_comment[1], 1],
510
+ [o.whitespace_trim + o.tag_comment[1], 0],
511
+ [o.whitespace_line_trim + o.tag_comment[1], 1],
512
+ [o.tag_comment[1], 2],
513
+ ];
514
+ /** @type {number[]} First code units of every comment closing marker. */
515
+ this.commentFirstChars = [
516
+ o.whitespace_trim.charCodeAt(0),
517
+ o.whitespace_line_trim.charCodeAt(0),
518
+ o.tag_comment[1].charCodeAt(0),
519
+ ];
520
+
521
+ /** @type {Source|null} */
522
+ this.source = null;
523
+ /** @type {string} */
524
+ this.code = '';
525
+ /** @type {number} */
526
+ this.cursor = 0;
527
+ /** @type {number} */
528
+ this.lineNumber = 1;
529
+ /** @type {number} */
530
+ this.end = 0;
531
+ /** @type {number} */
532
+ this.state = LexerState.DATA;
533
+ /** @type {number} */
534
+ this.currentVariableBlockLine = 1;
535
+ /** @type {Token[]} */
536
+ this.tokens = [];
537
+ /** @type {number[]} */
538
+ this.states = [];
539
+ /** @type {Array<[string, number]>} */
540
+ this.brackets = [];
541
+ /** @type {string[]} */
542
+ this.documentation = [];
543
+ /** @type {number} End offset of the previously pushed token's raw text. */
544
+ this.lastRawEnd = 0;
545
+ }
546
+
547
+ /**
548
+ * Tokenizes a template source.
549
+ *
550
+ * @param {Source} source The template source.
551
+ * @returns {TokenStream} The resulting token stream.
552
+ */
553
+ tokenize(source) {
554
+ this.source = source;
555
+ this.code = source.getCode();
556
+ this.cursor = 0;
557
+ this.lineNumber = 1;
558
+ this.end = this.code.length;
559
+ this.tokens.length = 0;
560
+ this.states.length = 0;
561
+ this.brackets.length = 0;
562
+ this.documentation.length = 0;
563
+ this.state = LexerState.DATA;
564
+ this.currentVariableBlockLine = 1;
565
+ this.lastRawEnd = 0;
566
+
567
+ while (this.cursor < this.end) {
568
+ switch (this.state) {
569
+ case LexerState.DATA:
570
+ this.lexData();
571
+ break;
572
+ case LexerState.BLOCK:
573
+ this.lexBlock();
574
+ break;
575
+ case LexerState.VARIABLE:
576
+ this.lexVariable();
577
+ break;
578
+ case LexerState.STRING:
579
+ this.lexString();
580
+ break;
581
+ case LexerState.INTERPOLATION:
582
+ this.lexInterpolation();
583
+ break;
584
+ }
585
+ }
586
+
587
+ this.pushToken(TokenType.EOF);
588
+
589
+ if (this.brackets.length) {
590
+ const [expect, lineNumber] = this.brackets[this.brackets.length - 1];
591
+ throw new SyntaxError(`Unclosed "${expect}".`, lineNumber, this.source);
592
+ }
593
+
594
+ return new TokenStream(this.tokens, this.source, this.options);
595
+ }
596
+
597
+ /**
598
+ * Moves the cursor to `to`, counting the newlines crossed in the source.
599
+ *
600
+ * `\n`, `\r` and `\r\n` each count as a single newline, exactly like
601
+ * `normalizeNewlines($text)` in the reference implementation. The scan is
602
+ * bounded to `[cursor, to)` so each code unit is examined at most once over
603
+ * the whole tokenize call.
604
+ *
605
+ * @param {number} to The new cursor position (absolute offset).
606
+ */
607
+ advance(to) {
608
+ const code = this.code;
609
+ let count = 0;
610
+ for (let i = this.cursor; i < to; i += 1) {
611
+ const c = code.charCodeAt(i);
612
+ if (c === LINE_FEED) {
613
+ count += 1;
614
+ } else if (c === CARRIAGE_RETURN) {
615
+ count += 1;
616
+ if (code.charCodeAt(i + 1) === LINE_FEED) {
617
+ i += 1;
618
+ }
619
+ }
620
+ }
621
+ this.lineNumber += count;
622
+ this.cursor = to;
623
+ }
624
+
625
+ /**
626
+ * Lexes data (static text) up to the next tag/comment start.
627
+ *
628
+ * The next delimiter is found by scanning forward on demand, so no
629
+ * precomputed table of token starts is ever built.
630
+ */
631
+ lexData() {
632
+ const code = this.code;
633
+ const o = this.options;
634
+ const first = this.delimiterFirstChar;
635
+
636
+ // find the next delimiter start
637
+ /** @type {number} */
638
+ let start;
639
+ if (first !== null) {
640
+ start = code.indexOf(first, this.cursor);
641
+ } else {
642
+ const firstChars = this.delimiterFirstChars;
643
+ let s = this.cursor;
644
+ while (s < this.end && firstChars.indexOf(code[s]) === -1) {
645
+ s += 1;
646
+ }
647
+ start = s < this.end ? s : -1;
648
+ }
649
+
650
+ let kind = 0;
651
+ let end = 0;
652
+ while (start !== -1) {
653
+ if (code.startsWith(o.tag_variable[0], start)) {
654
+ kind = 1;
655
+ end = start + o.tag_variable[0].length;
656
+ } else if (code.startsWith(o.tag_block[0], start)) {
657
+ kind = 2;
658
+ end = start + o.tag_block[0].length;
659
+ } else if (
660
+ code.startsWith(this.documentationStart, start) &&
661
+ (this.emptyCommentLookahead === null ||
662
+ !code.startsWith(this.emptyCommentLookahead, start + this.documentationStart.length))
663
+ ) {
664
+ kind = 4;
665
+ end = start + this.documentationStart.length;
666
+ } else if (code.startsWith(o.tag_comment[0], start)) {
667
+ kind = 3;
668
+ end = start + o.tag_comment[0].length;
669
+ }
670
+ if (kind !== 0) {
671
+ break;
672
+ }
673
+ // not a delimiter; look for the next candidate
674
+ if (first !== null) {
675
+ start = code.indexOf(first, start + 1);
676
+ } else {
677
+ let s = start + 1;
678
+ while (s < this.end && this.delimiterFirstChars.indexOf(code[s]) === -1) {
679
+ s += 1;
680
+ }
681
+ start = s < this.end ? s : -1;
682
+ }
683
+ }
684
+
685
+ if (start === -1) {
686
+ // rest of the template is static text
687
+ const text = code.slice(this.cursor);
688
+ this.pushToken(TokenType.TEXT, normalizeNewlines(text), this.cursor, null, text);
689
+ this.advance(this.end);
690
+ return;
691
+ }
692
+
693
+ // whitespace-trim marker on the delimiter trims the preceding text
694
+ let trim = '';
695
+ if (code.startsWith(o.whitespace_trim, end)) {
696
+ trim = o.whitespace_trim;
697
+ end += o.whitespace_trim.length;
698
+ } else if (code.startsWith(o.whitespace_line_trim, end)) {
699
+ trim = o.whitespace_line_trim;
700
+ end += o.whitespace_line_trim.length;
701
+ }
702
+
703
+ // text before the delimiter
704
+ const rawText = code.slice(this.cursor, start);
705
+ let text = rawText;
706
+ if (trim) {
707
+ text = o.whitespace_trim === trim
708
+ ? trimEnd(text, isPhpTrimCodePoint)
709
+ : trimEnd(text, (search) => isLineTrimCodeUnit(search, o.whitespace_line_chars));
710
+ }
711
+ this.pushToken(TokenType.TEXT, normalizeNewlines(text), this.cursor, null, rawText);
712
+ this.advance(start);
713
+ this.advance(end);
714
+
715
+ switch (kind) {
716
+ case 4:
717
+ this.lexComment(true);
718
+ break;
719
+ case 3:
720
+ this.lexComment();
721
+ break;
722
+ case 2:
723
+ this.lexBlockStart(start);
724
+ break;
725
+ default:
726
+ this.lexVariableStart(start);
727
+ break;
728
+ }
729
+ }
730
+
731
+ /** Handles a `{%` opening: verbatim, line directive, or block start. */
732
+ /**
733
+ * @param {number} rawStart The offset where the opening delimiter starts.
734
+ */
735
+ lexBlockStart(rawStart) {
736
+ const lineNumber = this.lineNumber;
737
+ const cursor = this.cursor;
738
+
739
+ // raw data (verbatim)?
740
+ if (this.matchBlockRaw()) {
741
+ this.documentation.length = 0;
742
+ this.lexRawData(rawStart);
743
+ return;
744
+ }
745
+
746
+ // {% line \d+ %}
747
+ const line = this.matchBlockLine();
748
+ if (line !== -1) {
749
+ this.documentation.length = 0;
750
+ this.lineNumber = line;
751
+ return;
752
+ }
753
+
754
+ this.pushToken(TokenType.BLOCK_START, '', cursor, lineNumber, this.code.slice(rawStart, cursor), rawStart);
755
+ this.pushState(LexerState.BLOCK);
756
+ this.currentVariableBlockLine = this.lineNumber;
757
+ }
758
+
759
+ /** Handles a `{{` opening. */
760
+ /**
761
+ * @param {number} rawStart The offset where the opening delimiter starts.
762
+ */
763
+ lexVariableStart(rawStart) {
764
+ const lineNumber = this.lineNumber;
765
+ const cursor = this.cursor;
766
+ this.pushToken(TokenType.VAR_START, '', cursor, lineNumber, this.code.slice(rawStart, cursor), rawStart);
767
+ this.pushState(LexerState.VARIABLE);
768
+ this.currentVariableBlockLine = this.lineNumber;
769
+ }
770
+
771
+ /** Lexes inside a block tag. */
772
+ lexBlock() {
773
+ if (!this.brackets.length) {
774
+ const end = this.matchBlockEnd();
775
+ if (end !== -1) {
776
+ this.pushClosingToken(TokenType.BLOCK_END, end);
777
+ this.popState();
778
+ return;
779
+ }
780
+ }
781
+ this.lexExpression();
782
+ }
783
+
784
+ /** Lexes inside a print statement. */
785
+ lexVariable() {
786
+ if (!this.brackets.length) {
787
+ const end = this.matchVariableEnd();
788
+ if (end !== -1) {
789
+ this.pushClosingToken(TokenType.VAR_END, end);
790
+ this.popState();
791
+ return;
792
+ }
793
+ }
794
+ this.lexExpression();
795
+ }
796
+
797
+ /** Lexes a single expression token. */
798
+ lexExpression() {
799
+ // whitespace
800
+ if (this.lexWhitespace() && this.cursor >= this.end) {
801
+ throw new SyntaxError(
802
+ `Unclosed "${LexerState.BLOCK === this.state ? 'block' : 'variable'}.`,
803
+ this.currentVariableBlockLine,
804
+ this.source,
805
+ );
806
+ }
807
+
808
+ // operators
809
+ if (this.lexOperator()) {
810
+ return;
811
+ }
812
+ // names
813
+ if (this.lexName()) {
814
+ return;
815
+ }
816
+ // numbers
817
+ if (this.lexNumber()) {
818
+ return;
819
+ }
820
+ // punctuation
821
+ if (this.lexPunctuation()) {
822
+ return;
823
+ }
824
+ // strings
825
+ if (this.lexQuotedString()) {
826
+ return;
827
+ }
828
+ // opening double-quoted string
829
+ if (this.lexStringStateOpen()) {
830
+ return;
831
+ }
832
+ // inline comment
833
+ if (this.lexInlineComment()) {
834
+ return;
835
+ }
836
+ // unlexable
837
+ throw new SyntaxError(
838
+ `Unexpected character "${this.code[this.cursor]}".`,
839
+ this.lineNumber,
840
+ this.source,
841
+ this.source ? this.source.getColumn(this.cursor) : null,
842
+ );
843
+ }
844
+
845
+ /**
846
+ * Consumes one or more whitespace code points at the cursor.
847
+ *
848
+ * Mirrors the `\s+` (PCRE byte mode) regular expression.
849
+ *
850
+ * @returns {boolean} Whether whitespace was consumed.
851
+ */
852
+ lexWhitespace() {
853
+ const code = this.code;
854
+ let i = this.cursor;
855
+ while (i < this.end && isWhitespaceCodePoint(code.charCodeAt(i))) {
856
+ i += 1;
857
+ }
858
+ if (i === this.cursor) {
859
+ return false;
860
+ }
861
+ this.advance(i);
862
+ return true;
863
+ }
864
+
865
+ /**
866
+ * Matches an operator at the cursor, pushing an OPERATOR token.
867
+ *
868
+ * Mirrors the `operator` regular expression of the reference
869
+ * implementation: operators are tried longest-first; whitespace inside an
870
+ * operator matches one or more whitespace code points; an operator ending
871
+ * with a letter must be followed by a delimiter character; an operator
872
+ * starting with a letter must not have a dot or pipe immediately before it.
873
+ *
874
+ * @returns {boolean} Whether an operator was matched.
875
+ */
876
+ lexOperator() {
877
+ const code = this.code;
878
+ const cursor = this.cursor;
879
+
880
+ // peek: only try operators whose first code unit matches the cursor
881
+ const operators = OPERATORS_BY_FIRST_CHAR.get(code.charCodeAt(cursor));
882
+ if (operators === undefined) {
883
+ return false;
884
+ }
885
+
886
+ // fast path: a lone single-character operator always matches here (its
887
+ // first code unit equals the cursor's, and no letter rules apply)
888
+ if (operators.length === 1 && operators[0].length === 1) {
889
+ const operator = operators[0];
890
+ if (OPENING_BRACKETS.includes(operator)) {
891
+ this.checkBrackets(operator);
892
+ }
893
+ this.pushToken(TokenType.OPERATOR, operator, cursor, null, operator, cursor);
894
+ this.advance(cursor + 1);
895
+ return true;
896
+ }
897
+
898
+ for (const operator of operators) {
899
+ let end;
900
+ if (OPERATORS_WITH_WHITESPACE.has(operator)) {
901
+ end = matchOperatorText(code, cursor, operator);
902
+ if (end === -1) {
903
+ continue;
904
+ }
905
+ } else {
906
+ if (!code.startsWith(operator, cursor)) {
907
+ continue;
908
+ }
909
+ end = cursor + operator.length;
910
+ }
911
+
912
+ // an operator ending with a letter must be followed by a delimiter
913
+ const last = operator.charCodeAt(operator.length - 1);
914
+ if (
915
+ isAsciiLetterCodePoint(last) &&
916
+ !isOperatorDelimiterCodePoint(code.codePointAt(end) ?? -1)
917
+ ) {
918
+ continue;
919
+ }
920
+
921
+ // an operator starting with a letter must not have a dot or pipe before
922
+ const first = operator.charCodeAt(0);
923
+ if (isAsciiLetterCodePoint(first) && hasDotOrPipeBefore(code, cursor)) {
924
+ continue;
925
+ }
926
+
927
+ if (OPENING_BRACKETS.includes(operator)) {
928
+ this.checkBrackets(operator);
929
+ }
930
+ this.pushToken(TokenType.OPERATOR, operator, null, null, code.slice(cursor, end), cursor);
931
+ this.advance(end);
932
+ return true;
933
+ }
934
+
935
+ return false;
936
+ }
937
+
938
+ /**
939
+ * Matches a name at the cursor, pushing a NAME token.
940
+ *
941
+ * Mirrors the byte-oriented `REGEX_NAME` of the reference implementation:
942
+ * a letter, underscore or any code point from U+007F onward, followed by
943
+ * the same set plus digits.
944
+ *
945
+ * @returns {boolean} Whether a name was matched.
946
+ */
947
+ lexName() {
948
+ const code = this.code;
949
+ const len = this.end;
950
+ const start = this.cursor;
951
+ let i = start;
952
+
953
+ // ASCII fast path: letters and underscore are always name-start code points
954
+ const firstUnit = code.charCodeAt(i);
955
+ if (
956
+ (firstUnit >= 0x0041 && firstUnit <= 0x005a) ||
957
+ (firstUnit >= 0x0061 && firstUnit <= 0x007a) ||
958
+ firstUnit === LOW_LINE
959
+ ) {
960
+ i += 1;
961
+ } else {
962
+ const first = code.codePointAt(i) ?? -1;
963
+ if (!isNameStartCodePoint(first)) {
964
+ return false;
965
+ }
966
+ i += 1 + +(first > 0xffff);
967
+ }
968
+
969
+ while (i < len) {
970
+ const unit = code.charCodeAt(i);
971
+ if (
972
+ (unit >= 0x0041 && unit <= 0x005a) ||
973
+ (unit >= 0x0061 && unit <= 0x007a) ||
974
+ (unit >= 0x0030 && unit <= 0x0039) ||
975
+ unit === LOW_LINE
976
+ ) {
977
+ i += 1;
978
+ continue;
979
+ }
980
+ // a non-ASCII code point (or a surrogate half): decode and test it
981
+ const cp = code.codePointAt(i) ?? -1;
982
+ if (!isNameCodePoint(cp)) {
983
+ break;
984
+ }
985
+ i += 1 + +(cp > 0xffff);
986
+ }
987
+
988
+ this.pushToken(TokenType.NAME, code.slice(start, i), null, null, code.slice(start, i), start);
989
+ this.advance(i);
990
+ return true;
991
+ }
992
+
993
+ /**
994
+ * Matches a number literal at the cursor, pushing a NUMBER token.
995
+ *
996
+ * Mirrors the `REGEX_NUMBER` of the reference implementation: an integer
997
+ * part (with optional underscore-separated digit groups), an optional
998
+ * fractional part and an optional exponent part. Each optional part is
999
+ * only consumed when it is fully valid.
1000
+ *
1001
+ * @returns {boolean} Whether a number was matched.
1002
+ */
1003
+ lexNumber() {
1004
+ const code = this.code;
1005
+ const len = this.end;
1006
+ const start = this.cursor;
1007
+ let i = start;
1008
+
1009
+ if (i >= len || !isDigitCodePoint(code.charCodeAt(i))) {
1010
+ return false;
1011
+ }
1012
+ while (i < len && isDigitCodePoint(code.charCodeAt(i))) {
1013
+ i += 1;
1014
+ }
1015
+ i = consumeUnderscoreGroups(code, i, len);
1016
+
1017
+ // fractional part: only when a digit follows the dot
1018
+ if (
1019
+ i + 1 < len &&
1020
+ code.charCodeAt(i) === FULL_STOP &&
1021
+ isDigitCodePoint(code.charCodeAt(i + 1))
1022
+ ) {
1023
+ i += 1;
1024
+ while (i < len && isDigitCodePoint(code.charCodeAt(i))) {
1025
+ i += 1;
1026
+ }
1027
+ i = consumeUnderscoreGroups(code, i, len);
1028
+ }
1029
+
1030
+ // exponent part: only when a (possibly signed) digit follows
1031
+ const exponent = code.charCodeAt(i);
1032
+ if (exponent === LATIN_SMALL_LETTER_E || exponent === LATIN_CAPITAL_LETTER_E) {
1033
+ let j = i + 1;
1034
+ const sign = code.charCodeAt(j);
1035
+ if (sign === PLUS_SIGN || sign === HYPHEN_MINUS) {
1036
+ j += 1;
1037
+ }
1038
+ if (j < len && isDigitCodePoint(code.charCodeAt(j))) {
1039
+ i = j;
1040
+ while (i < len && isDigitCodePoint(code.charCodeAt(i))) {
1041
+ i += 1;
1042
+ }
1043
+ i = consumeUnderscoreGroups(code, i, len);
1044
+ }
1045
+ }
1046
+
1047
+ this.pushToken(TokenType.NUMBER, numberValue(code, start, i), null, null, code.slice(start, i), start);
1048
+ this.advance(i);
1049
+ return true;
1050
+ }
1051
+
1052
+ /**
1053
+ * Matches a punctuation character at the cursor, pushing a PUNCTUATION
1054
+ * token.
1055
+ *
1056
+ * @returns {boolean} Whether a punctuation character was matched.
1057
+ */
1058
+ lexPunctuation() {
1059
+ const code = this.code;
1060
+ const c = code.charCodeAt(this.cursor);
1061
+ const p = this.options.punctuation;
1062
+ if (p ? !p.includes(code[this.cursor]) : !isDefaultPunctuation(c)) {
1063
+ return false;
1064
+ }
1065
+ const character = code[this.cursor];
1066
+ this.checkBrackets(character);
1067
+ this.pushToken(TokenType.PUNCTUATION, character, this.cursor, null, character, this.cursor);
1068
+ this.advance(this.cursor + 1);
1069
+ return true;
1070
+ }
1071
+
1072
+ /**
1073
+ * Matches a complete single- or double-quoted string at the cursor,
1074
+ * pushing a STRING token.
1075
+ *
1076
+ * Mirrors `REGEX_STRING`: within a double-quoted string an unescaped `#`
1077
+ * is not part of the content, so such a string is not matched here and is
1078
+ * instead handled via the string state. Within a single-quoted string `#`
1079
+ * is an ordinary content character.
1080
+ *
1081
+ * @returns {boolean} Whether a complete string was matched.
1082
+ */
1083
+ lexQuotedString() {
1084
+ const code = this.code;
1085
+ const len = this.end;
1086
+ const start = this.cursor;
1087
+ const quote = code.charCodeAt(start);
1088
+ if (quote !== QUOTATION_MARK && quote !== APOSTROPHE) {
1089
+ return false;
1090
+ }
1091
+
1092
+ let i = start + 1;
1093
+ while (i < len) {
1094
+ const c = code.charCodeAt(i);
1095
+ if (c === quote) {
1096
+ const content = code.slice(start + 1, i);
1097
+ this.pushToken(TokenType.STRING, stripcslashes(normalizeNewlines(content), code[start]), start, null, code.slice(start, i + 1), start);
1098
+ this.advance(i + 1);
1099
+ return true;
1100
+ }
1101
+ if (c === REVERSE_SOLIDUS) {
1102
+ if (i + 1 >= len) {
1103
+ return false;
1104
+ }
1105
+ i += 2;
1106
+ continue;
1107
+ }
1108
+ if (quote === QUOTATION_MARK && c === NUMBER_SIGN) {
1109
+ return false;
1110
+ }
1111
+ i += 1;
1112
+ }
1113
+
1114
+ return false;
1115
+ }
1116
+
1117
+ /**
1118
+ * Matches an opening double-quote at the cursor, entering the string state.
1119
+ *
1120
+ * @returns {boolean} Whether a double quote was matched.
1121
+ */
1122
+ lexStringStateOpen() {
1123
+ if (this.code.charCodeAt(this.cursor) !== QUOTATION_MARK) {
1124
+ return false;
1125
+ }
1126
+ this.brackets.push(['"', this.lineNumber]);
1127
+ this.pushState(LexerState.STRING);
1128
+ this.advance(this.cursor + 1);
1129
+ return true;
1130
+ }
1131
+
1132
+ /**
1133
+ * Matches an inline comment (running to the end of the line) at the cursor.
1134
+ *
1135
+ * Mirrors `REGEX_RAW_INLINE_COMMENT` (`#[^\r\n]*`).
1136
+ *
1137
+ * @returns {boolean} Whether an inline comment was matched.
1138
+ */
1139
+ lexInlineComment() {
1140
+ const code = this.code;
1141
+ const len = this.end;
1142
+ if (code.charCodeAt(this.cursor) !== NUMBER_SIGN) {
1143
+ return false;
1144
+ }
1145
+ let i = this.cursor;
1146
+ while (i < len) {
1147
+ const c = code.charCodeAt(i);
1148
+ if (c === LINE_FEED || c === CARRIAGE_RETURN) {
1149
+ break;
1150
+ }
1151
+ i += 1;
1152
+ }
1153
+ const comment = code.slice(this.cursor, i);
1154
+ if (comment.startsWith('##')) {
1155
+ this.addDocumentation(comment.slice(2));
1156
+ }
1157
+ this.advance(i);
1158
+ return true;
1159
+ }
1160
+
1161
+ /**
1162
+ * Matches a `{% verbatim %}` block opening at the cursor, consuming it.
1163
+ *
1164
+ * Mirrors the sticky `lex_block_raw` regular expression.
1165
+ *
1166
+ * @returns {boolean} Whether a verbatim block opening was matched.
1167
+ */
1168
+ matchBlockRaw() {
1169
+ const code = this.code;
1170
+ const o = this.options;
1171
+ let i = this.cursor;
1172
+
1173
+ while (i < this.end && isWhitespaceCodePoint(code.charCodeAt(i))) {
1174
+ i += 1;
1175
+ }
1176
+ if (!code.startsWith(VERBATIM, i)) {
1177
+ return false;
1178
+ }
1179
+ i += VERBATIM.length;
1180
+ while (i < this.end && isWhitespaceCodePoint(code.charCodeAt(i))) {
1181
+ i += 1;
1182
+ }
1183
+
1184
+ // closing tag with optional trim
1185
+ const closingTrim = o.whitespace_trim + o.tag_block[1];
1186
+ if (code.startsWith(closingTrim, i)) {
1187
+ i += closingTrim.length;
1188
+ while (i < this.end && isWhitespaceCodePoint(code.charCodeAt(i))) {
1189
+ i += 1;
1190
+ }
1191
+ this.advance(i);
1192
+ return true;
1193
+ }
1194
+ const lineTrimEnd = o.whitespace_line_trim + o.tag_block[1];
1195
+ if (code.startsWith(lineTrimEnd, i)) {
1196
+ i += lineTrimEnd.length;
1197
+ while (i < this.end && isLineTrimCodeUnit(code.charCodeAt(i), o.whitespace_line_chars)) {
1198
+ i += 1;
1199
+ }
1200
+ this.advance(i);
1201
+ return true;
1202
+ }
1203
+ if (code.startsWith(o.tag_block[1], i)) {
1204
+ i += o.tag_block[1].length;
1205
+ this.advance(i);
1206
+ return true;
1207
+ }
1208
+
1209
+ return false;
1210
+ }
1211
+
1212
+ /**
1213
+ * Matches a `{% line \d+ %}` directive at the cursor, consuming it.
1214
+ *
1215
+ * Mirrors the sticky `lex_block_line` regular expression.
1216
+ *
1217
+ * @returns {number} The line number, or -1 when no directive matches.
1218
+ */
1219
+ matchBlockLine() {
1220
+ const code = this.code;
1221
+ const o = this.options;
1222
+ let i = this.cursor;
1223
+
1224
+ while (i < this.end && isWhitespaceCodePoint(code.charCodeAt(i))) {
1225
+ i += 1;
1226
+ }
1227
+ if (!code.startsWith(LINE, i)) {
1228
+ return -1;
1229
+ }
1230
+ i += LINE.length;
1231
+
1232
+ // `\s+` between the word and the number is required
1233
+ if (i >= this.end || !isWhitespaceCodePoint(code.charCodeAt(i))) {
1234
+ return -1;
1235
+ }
1236
+ while (i < this.end && isWhitespaceCodePoint(code.charCodeAt(i))) {
1237
+ i += 1;
1238
+ }
1239
+
1240
+ if (i >= this.end || !isDigitCodePoint(code.charCodeAt(i))) {
1241
+ return -1;
1242
+ }
1243
+ const digitsStart = i;
1244
+ while (i < this.end && isDigitCodePoint(code.charCodeAt(i))) {
1245
+ i += 1;
1246
+ }
1247
+ const lineNumber = parseInt(code.slice(digitsStart, i), 10);
1248
+
1249
+ while (i < this.end && isWhitespaceCodePoint(code.charCodeAt(i))) {
1250
+ i += 1;
1251
+ }
1252
+ if (!code.startsWith(o.tag_block[1], i)) {
1253
+ return -1;
1254
+ }
1255
+ i += o.tag_block[1].length;
1256
+
1257
+ this.advance(i);
1258
+ return lineNumber;
1259
+ }
1260
+
1261
+ /**
1262
+ * Matches the closing `}}` of a print statement at the cursor.
1263
+ *
1264
+ * Mirrors the sticky `lex_var` regular expression.
1265
+ *
1266
+ * @returns {number} The end offset, or -1.
1267
+ */
1268
+ matchVariableEnd() {
1269
+ return this.matchClosingDelimiter(this.options.tag_variable[1], false);
1270
+ }
1271
+
1272
+ /**
1273
+ * Matches the closing `%}` of a block tag at the cursor.
1274
+ *
1275
+ * Mirrors the sticky `lex_block` regular expression.
1276
+ *
1277
+ * @returns {number} The end offset, or -1.
1278
+ */
1279
+ matchBlockEnd() {
1280
+ return this.matchClosingDelimiter(this.options.tag_block[1], true);
1281
+ }
1282
+
1283
+ /**
1284
+ * Matches a closing delimiter (`}}` or `%}`) at the cursor.
1285
+ *
1286
+ * Mirrors the `lex_var` / `lex_block` regular expressions: the `-` marker
1287
+ * is followed by optional whitespace (plus a single newline for block
1288
+ * tags), the `~` marker by the line-trim characters, and a plain closing
1289
+ * tag may be followed by a single newline for block tags.
1290
+ *
1291
+ * @param {string} tagEnd The closing delimiter literal.
1292
+ * @param {boolean} consumesTrailingNewline Whether a plain closing tag may
1293
+ * be followed by a newline.
1294
+ * @returns {number} The end offset, or -1.
1295
+ */
1296
+ matchClosingDelimiter(tagEnd, consumesTrailingNewline) {
1297
+ const code = this.code;
1298
+ const o = this.options;
1299
+
1300
+ // peek: the closing tag must start with whitespace, a trim marker or the
1301
+ // delimiter itself; anything else cannot possibly be a closing tag
1302
+ const first = code.charCodeAt(this.cursor);
1303
+ if (
1304
+ !isWhitespaceCodePoint(first) &&
1305
+ first !== o.whitespace_trim.charCodeAt(0) &&
1306
+ first !== o.whitespace_line_trim.charCodeAt(0) &&
1307
+ first !== tagEnd.charCodeAt(0)
1308
+ ) {
1309
+ return -1;
1310
+ }
1311
+
1312
+ let i = this.cursor;
1313
+ while (i < this.end && isWhitespaceCodePoint(code.charCodeAt(i))) {
1314
+ i += 1;
1315
+ }
1316
+
1317
+ const closingTrim = o.whitespace_trim + tagEnd;
1318
+ if (code.startsWith(closingTrim, i)) {
1319
+ i += closingTrim.length;
1320
+ while (i < this.end && isWhitespaceCodePoint(code.charCodeAt(i))) {
1321
+ i += 1;
1322
+ }
1323
+ return i;
1324
+ }
1325
+
1326
+ const lineTrimEnd = o.whitespace_line_trim + tagEnd;
1327
+ if (code.startsWith(lineTrimEnd, i)) {
1328
+ i += lineTrimEnd.length;
1329
+ while (i < this.end && isLineTrimCodeUnit(code.charCodeAt(i), o.whitespace_line_chars)) {
1330
+ i += 1;
1331
+ }
1332
+ return i;
1333
+ }
1334
+
1335
+ if (code.startsWith(tagEnd, i)) {
1336
+ i += tagEnd.length;
1337
+ if (consumesTrailingNewline) {
1338
+ const c = code.charCodeAt(i);
1339
+ if (c === CARRIAGE_RETURN) {
1340
+ i += 1;
1341
+ if (code.charCodeAt(i) === LINE_FEED) {
1342
+ i += 1;
1343
+ }
1344
+ } else if (c === LINE_FEED) {
1345
+ i += 1;
1346
+ }
1347
+ }
1348
+ return i;
1349
+ }
1350
+
1351
+ return -1;
1352
+ }
1353
+
1354
+ /**
1355
+ * Matches the start of a string interpolation (`#{`) at the cursor.
1356
+ *
1357
+ * Mirrors the `interpolation_start` regular expression: `#{`, optional
1358
+ * whitespace and an optional closing brace.
1359
+ *
1360
+ * @returns {number} The end offset, or -1.
1361
+ */
1362
+ matchInterpolationStart() {
1363
+ const code = this.code;
1364
+ if (
1365
+ code.charCodeAt(this.cursor) !== NUMBER_SIGN ||
1366
+ code.charCodeAt(this.cursor + 1) !== LEFT_CURLY_BRACKET
1367
+ ) {
1368
+ return -1;
1369
+ }
1370
+ let i = this.cursor + 2;
1371
+ while (i < this.end && isWhitespaceCodePoint(code.charCodeAt(i))) {
1372
+ i += 1;
1373
+ }
1374
+ if (code.charCodeAt(i) === RIGHT_CURLY_BRACKET) {
1375
+ i += 1;
1376
+ }
1377
+ return i;
1378
+ }
1379
+
1380
+ /**
1381
+ * Matches the end of a string interpolation (`}`) at the cursor.
1382
+ *
1383
+ * Mirrors the `interpolation_end` regular expression: optional whitespace
1384
+ * then `}`.
1385
+ *
1386
+ * @returns {number} The end offset, or -1.
1387
+ */
1388
+ matchInterpolationEnd() {
1389
+ const code = this.code;
1390
+ let i = this.cursor;
1391
+ while (i < this.end && isWhitespaceCodePoint(code.charCodeAt(i))) {
1392
+ i += 1;
1393
+ }
1394
+ if (code.charCodeAt(i) !== RIGHT_CURLY_BRACKET) {
1395
+ return -1;
1396
+ }
1397
+ return i + 1;
1398
+ }
1399
+
1400
+ /**
1401
+ * Matches a part of a double-quoted string with no unescaped `#{`.
1402
+ *
1403
+ * Mirrors `REGEX_DQ_STRING_PART`: a `#` is consumed unless it is the start
1404
+ * of an interpolation (`#{`); a backslash escapes the following code
1405
+ * point. The match may be empty.
1406
+ *
1407
+ * @returns {number} The end offset (may equal the cursor).
1408
+ */
1409
+ matchDoubleQuoteStringPart() {
1410
+ const code = this.code;
1411
+ const len = this.end;
1412
+ let i = this.cursor;
1413
+
1414
+ while (i < len) {
1415
+ const c = code.charCodeAt(i);
1416
+ if (c === NUMBER_SIGN) {
1417
+ if (code.charCodeAt(i + 1) === LEFT_CURLY_BRACKET) {
1418
+ break;
1419
+ }
1420
+ i += 1;
1421
+ continue;
1422
+ }
1423
+ if (c === REVERSE_SOLIDUS) {
1424
+ if (i + 1 >= len) {
1425
+ break;
1426
+ }
1427
+ i += 2;
1428
+ continue;
1429
+ }
1430
+ if (c === QUOTATION_MARK) {
1431
+ break;
1432
+ }
1433
+ i += 1;
1434
+ }
1435
+
1436
+ return i;
1437
+ }
1438
+
1439
+ /** Lexes the body of a `verbatim` block as literal text. */
1440
+ /**
1441
+ * @param {number} rawStart The offset where the opening delimiter starts.
1442
+ */
1443
+ lexRawData(rawStart) {
1444
+ const code = this.code;
1445
+ const o = this.options;
1446
+ const blockStartFirst = o.tag_block[0][0];
1447
+
1448
+ // search for the `{% endverbatim %}` closing marker
1449
+ let markerStart = -1;
1450
+ let markerEnd = -1;
1451
+ let trim = '';
1452
+ let i = this.cursor;
1453
+ while (i < code.length) {
1454
+ const start = code.indexOf(blockStartFirst, i);
1455
+ if (start === -1) {
1456
+ break;
1457
+ }
1458
+ i = start + 1;
1459
+ if (!code.startsWith(o.tag_block[0], start)) {
1460
+ continue;
1461
+ }
1462
+ let j = start + o.tag_block[0].length;
1463
+ if (code.startsWith(o.whitespace_trim, j)) {
1464
+ trim = o.whitespace_trim;
1465
+ j += o.whitespace_trim.length;
1466
+ } else if (code.startsWith(o.whitespace_line_trim, j)) {
1467
+ trim = o.whitespace_line_trim;
1468
+ j += o.whitespace_line_trim.length;
1469
+ }
1470
+ while (j < code.length && isWhitespaceCodePoint(code.charCodeAt(j))) {
1471
+ j += 1;
1472
+ }
1473
+ if (!code.startsWith(ENDVERBATIM, j)) {
1474
+ continue;
1475
+ }
1476
+ j += ENDVERBATIM.length;
1477
+ while (j < code.length && isWhitespaceCodePoint(code.charCodeAt(j))) {
1478
+ j += 1;
1479
+ }
1480
+
1481
+ // closing tag with optional trim
1482
+ const closingTrim = o.whitespace_trim + o.tag_block[1];
1483
+ if (code.startsWith(closingTrim, j)) {
1484
+ j += closingTrim.length;
1485
+ while (j < code.length && isWhitespaceCodePoint(code.charCodeAt(j))) {
1486
+ j += 1;
1487
+ }
1488
+ markerStart = start;
1489
+ markerEnd = j;
1490
+ break;
1491
+ }
1492
+ const lineTrimEnd = o.whitespace_line_trim + o.tag_block[1];
1493
+ if (code.startsWith(lineTrimEnd, j)) {
1494
+ j += lineTrimEnd.length;
1495
+ while (j < code.length && isLineTrimCodeUnit(code.charCodeAt(j), o.whitespace_line_chars)) {
1496
+ j += 1;
1497
+ }
1498
+ markerStart = start;
1499
+ markerEnd = j;
1500
+ break;
1501
+ }
1502
+ if (code.startsWith(o.tag_block[1], j)) {
1503
+ j += o.tag_block[1].length;
1504
+ markerStart = start;
1505
+ markerEnd = j;
1506
+ break;
1507
+ }
1508
+ }
1509
+
1510
+ if (markerStart === -1) {
1511
+ throw new SyntaxError(
1512
+ 'Unexpected end of file: Unclosed "verbatim" block.',
1513
+ this.lineNumber,
1514
+ this.source,
1515
+ );
1516
+ }
1517
+
1518
+ const offset = this.cursor;
1519
+ let text = code.slice(this.cursor, markerStart);
1520
+ this.advance(markerEnd);
1521
+
1522
+ // trim?
1523
+ if (trim !== '') {
1524
+ text = o.whitespace_trim === trim
1525
+ ? trimEnd(text, isPhpTrimCodePoint)
1526
+ : trimEnd(text, (search) => isLineTrimCodeUnit(search, o.whitespace_line_chars));
1527
+ }
1528
+
1529
+ this.pushToken(TokenType.TEXT, normalizeNewlines(text), offset, null, code.slice(rawStart, markerEnd), rawStart);
1530
+ }
1531
+
1532
+ /**
1533
+ * Lexes a comment body.
1534
+ *
1535
+ * @param {boolean} [isDocumentation] Whether it is a documentation comment.
1536
+ */
1537
+ lexComment(isDocumentation = false) {
1538
+ const code = this.code;
1539
+ const markers = isDocumentation ? this.docCommentMarkers : this.commentMarkers;
1540
+ const firstChars = this.commentFirstChars;
1541
+
1542
+ let index = -1;
1543
+ let end = -1;
1544
+ for (let p = this.cursor; p < code.length; p += 1) {
1545
+ const pc = code.charCodeAt(p);
1546
+ if (pc !== firstChars[0] && pc !== firstChars[1] && pc !== firstChars[2]) {
1547
+ continue;
1548
+ }
1549
+ for (let k = 0; k < markers.length; k += 1) {
1550
+ const marker = markers[k][0];
1551
+ if (!code.startsWith(marker, p)) {
1552
+ continue;
1553
+ }
1554
+ let j = p + marker.length;
1555
+ const kind = markers[k][1];
1556
+ if (kind === 0) {
1557
+ while (j < code.length && isWhitespaceCodePoint(code.charCodeAt(j))) {
1558
+ j += 1;
1559
+ }
1560
+ } else if (kind === 1) {
1561
+ while (j < code.length && isLineTrimCodeUnit(code.charCodeAt(j), this.options.whitespace_line_chars)) {
1562
+ j += 1;
1563
+ }
1564
+ } else {
1565
+ const c = code.charCodeAt(j);
1566
+ if (c === CARRIAGE_RETURN) {
1567
+ j += 1;
1568
+ if (code.charCodeAt(j) === LINE_FEED) {
1569
+ j += 1;
1570
+ }
1571
+ } else if (c === LINE_FEED) {
1572
+ j += 1;
1573
+ }
1574
+ }
1575
+ index = p;
1576
+ end = j;
1577
+ break;
1578
+ }
1579
+ if (index !== -1) {
1580
+ break;
1581
+ }
1582
+ }
1583
+
1584
+ if (index === -1) {
1585
+ throw new SyntaxError('Unclosed comment.', this.lineNumber, this.source);
1586
+ }
1587
+
1588
+ const comment = code.slice(this.cursor, index);
1589
+ if (isDocumentation) {
1590
+ let documentation = comment;
1591
+ // support a symmetric "##}" closing marker
1592
+ if (documentation.endsWith('#')) {
1593
+ documentation = documentation.slice(0, -1);
1594
+ }
1595
+ this.addDocumentation(documentation);
1596
+ }
1597
+
1598
+ this.advance(end);
1599
+ }
1600
+
1601
+ /** Lexes inside a double-quoted string. */
1602
+ lexString() {
1603
+ const code = this.code;
1604
+
1605
+ // interpolation start
1606
+ const interpEnd = this.matchInterpolationStart();
1607
+ if (interpEnd !== -1) {
1608
+ this.brackets.push([this.options.interpolation[0], this.lineNumber]);
1609
+ this.pushToken(TokenType.INTERPOLATION_START, '', this.cursor, null, this.code.slice(this.cursor, interpEnd), this.cursor);
1610
+ this.advance(interpEnd);
1611
+ this.pushState(LexerState.INTERPOLATION);
1612
+ return;
1613
+ }
1614
+
1615
+ // string part
1616
+ const partEnd = this.matchDoubleQuoteStringPart();
1617
+ if (partEnd > this.cursor) {
1618
+ const content = code.slice(this.cursor, partEnd);
1619
+ this.pushToken(TokenType.STRING, stripcslashes(normalizeNewlines(content), '"'), this.cursor, null, content, this.cursor);
1620
+ this.advance(partEnd);
1621
+ return;
1622
+ }
1623
+
1624
+ // closing quote
1625
+ if (code.charCodeAt(this.cursor) === QUOTATION_MARK) {
1626
+ const top = this.brackets[this.brackets.length - 1];
1627
+ const [expect, lineNumber] = top;
1628
+ if ('"' !== code[this.cursor]) {
1629
+ throw new SyntaxError(`Unclosed "${expect}".`, lineNumber, this.source);
1630
+ }
1631
+ this.brackets.pop();
1632
+ this.popState();
1633
+ this.advance(this.cursor + 1);
1634
+ return;
1635
+ }
1636
+
1637
+ // unlexable
1638
+ throw new SyntaxError(
1639
+ `Unexpected character "${code[this.cursor]}".`,
1640
+ this.lineNumber,
1641
+ this.source,
1642
+ this.source ? this.source.getColumn(this.cursor) : null,
1643
+ );
1644
+ }
1645
+
1646
+ /** Lexes inside a string interpolation. */
1647
+ lexInterpolation() {
1648
+ const bracket = this.brackets[this.brackets.length - 1];
1649
+ if (bracket && bracket[0] === this.options.interpolation[0]) {
1650
+ const end = this.matchInterpolationEnd();
1651
+ if (end !== -1) {
1652
+ this.brackets.pop();
1653
+ this.pushClosingToken(TokenType.INTERPOLATION_END, end);
1654
+ this.popState();
1655
+ return;
1656
+ }
1657
+ }
1658
+ this.lexExpression();
1659
+ }
1660
+
1661
+ /**
1662
+ * Pushes a token, skipping empty text tokens and attaching documentation.
1663
+ *
1664
+ * @param {number} type
1665
+ * @param {unknown} [value]
1666
+ * @param {number|null} [offset]
1667
+ * @param {number|null} [lineNumber]
1668
+ * @param {string|null} [raw] The exact source text of the token.
1669
+ * @param {number|null} [rawStart] The offset where the token's raw text begins.
1670
+ */
1671
+ pushToken(type, value = '', offset = null, lineNumber = null, raw = null, rawStart = null) {
1672
+ // do not push empty text tokens
1673
+ if (TokenType.TEXT === type && value === '') {
1674
+ return;
1675
+ }
1676
+
1677
+ let documentation = null;
1678
+ if (this.documentation.length && (TokenType.TEXT !== type || trimLikePhp(String(value)) !== '')) {
1679
+ documentation = this.documentation.join('\n');
1680
+ this.documentation.length = 0;
1681
+ }
1682
+
1683
+ const start = rawStart ?? offset ?? this.cursor;
1684
+ const rawText = raw ?? '';
1685
+ const leading = this.code.slice(this.lastRawEnd, start);
1686
+ this.lastRawEnd = start + rawText.length;
1687
+
1688
+ const token = new Token(type, value, lineNumber ?? this.lineNumber, offset ?? this.cursor, documentation);
1689
+ token.withSource(rawText, leading, start - leading.length);
1690
+ this.tokens.push(token);
1691
+ }
1692
+
1693
+ /**
1694
+ * Records a documentation fragment.
1695
+ *
1696
+ * @param {string} documentation
1697
+ */
1698
+ addDocumentation(documentation) {
1699
+ documentation = trimLikePhp(normalizeNewlines(documentation));
1700
+ if (documentation !== '') {
1701
+ this.documentation.push(documentation);
1702
+ }
1703
+ }
1704
+
1705
+ /**
1706
+ * Pushes a closing delimiter token, consuming leading whitespace.
1707
+ *
1708
+ * @param {number} type
1709
+ * @param {number} end The end offset of the closing delimiter match.
1710
+ */
1711
+ pushClosingToken(type, end) {
1712
+ const code = this.code;
1713
+ let i = this.cursor;
1714
+ while (i < end && isPhpTrimCodePoint(code.charCodeAt(i))) {
1715
+ i += 1;
1716
+ }
1717
+ if (i > this.cursor) {
1718
+ this.advance(i);
1719
+ }
1720
+ this.pushToken(type, '', i, null, code.slice(i, end), i);
1721
+ this.advance(end);
1722
+ }
1723
+
1724
+ /**
1725
+ * Pushes a lexer state.
1726
+ *
1727
+ * @param {number} state
1728
+ */
1729
+ pushState(state) {
1730
+ this.states.push(this.state);
1731
+ this.state = state;
1732
+ }
1733
+
1734
+ /** Pops a lexer state. */
1735
+ popState() {
1736
+ if (this.states.length === 0) {
1737
+ throw new Error('Cannot pop state without a previous state.');
1738
+ }
1739
+ this.state = this.states.pop() ?? 0;
1740
+ }
1741
+
1742
+ /**
1743
+ * Tracks bracket matching.
1744
+ *
1745
+ * @param {string} code The bracket character.
1746
+ */
1747
+ checkBrackets(code) {
1748
+ if (OPENING_BRACKETS.includes(code)) {
1749
+ this.brackets.push([code, this.lineNumber]);
1750
+ } else if (CLOSING_BRACKETS.includes(code)) {
1751
+ if (!this.brackets.length) {
1752
+ throw new SyntaxError(
1753
+ `Unexpected "${code}".`,
1754
+ this.lineNumber,
1755
+ this.source,
1756
+ this.source ? this.source.getColumn(this.cursor) : null,
1757
+ );
1758
+ }
1759
+ const [expect, lineNumber] = this.brackets.pop() ?? ['', 0];
1760
+ const expectedClosing = CLOSING_BRACKETS[OPENING_BRACKETS.indexOf(expect)];
1761
+ if (code !== expectedClosing) {
1762
+ throw new SyntaxError(`Unclosed "${expect}".`, lineNumber, this.source);
1763
+ }
1764
+ }
1765
+ }
1766
+ }