@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/LICENSE +24 -0
- package/package.json +10 -0
- package/src/code-points/code-points.js +54 -0
- package/src/code-points/ranges.js +161 -0
- package/src/errors.js +283 -0
- package/src/index.js +11 -0
- package/src/lexer.js +1766 -0
- package/src/operators.js +65 -0
- package/src/token-stream.js +188 -0
- package/src/token.js +356 -0
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
|
+
}
|