@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.
@@ -0,0 +1,65 @@
1
+ // @ts-check
2
+ /**
3
+ * The set of operators recognized by the lexer.
4
+ *
5
+ * Matches the operators registered by `CoreExtension::getExpressionParsers()`
6
+ * in the reference implementation. Operators are matched longest-first; word
7
+ * operators are only matched when followed by a delimiter character and are
8
+ * not matched when immediately preceded by `.` or `|`.
9
+ *
10
+ * @module twig-tokenizer
11
+ */
12
+
13
+ /**
14
+ * All operator tokens (names and aliases) known to the lexer.
15
+ *
16
+ * @type {string[]}
17
+ */
18
+ export const OPERATORS = [
19
+ 'not',
20
+ '...',
21
+ '-',
22
+ '+',
23
+ '?:',
24
+ '? :',
25
+ '??',
26
+ 'or',
27
+ 'xor',
28
+ 'and',
29
+ 'b-or',
30
+ 'b-xor',
31
+ 'b-and',
32
+ '==',
33
+ '!=',
34
+ '<=>',
35
+ '<',
36
+ '>',
37
+ '>=',
38
+ '<=',
39
+ 'not in',
40
+ 'in',
41
+ 'matches',
42
+ 'starts with',
43
+ 'ends with',
44
+ 'has some',
45
+ 'has every',
46
+ '===',
47
+ '!==',
48
+ '..',
49
+ '~',
50
+ '*',
51
+ '/',
52
+ '//',
53
+ '%',
54
+ '**',
55
+ '?',
56
+ '=',
57
+ 'is',
58
+ 'is not',
59
+ '|',
60
+ '(',
61
+ '.',
62
+ '?.',
63
+ '[',
64
+ '=>',
65
+ ];
@@ -0,0 +1,188 @@
1
+ // @ts-check
2
+ /**
3
+ * The {@link TokenStream} class.
4
+ *
5
+ * Mirrors `src/TokenStream.php` of the reference implementation.
6
+ *
7
+ * @module twig-tokenizer
8
+ */
9
+ // eslint-disable-next-line no-unused-vars -- used in JSDoc type annotations
10
+ import { Token, TokenType, typeToEnglish } from './token.js';
11
+ import { SyntaxError } from './errors.js';
12
+
13
+ /**
14
+ * A sequence of {@link Token} objects with a moving pointer.
15
+ */
16
+ export class TokenStream {
17
+ /**
18
+ * @param {Token[]} tokens The full token list (must end with an EOF token).
19
+ * @param {import('./lexer.js').Source|null} [source] The template source.
20
+ * @param {import('./lexer.js').LexerOptions|null} [options] The lexer options.
21
+ */
22
+ constructor(tokens, source = null, options = null) {
23
+ /** @type {Token[]} */
24
+ this.tokens = tokens;
25
+ /** @type {import('./lexer.js').Source|null} */
26
+ this.source = source;
27
+ /** @type {import('./lexer.js').LexerOptions|null} */
28
+ this.options = options;
29
+ /** @type {number} The current pointer. */
30
+ this.current = 0;
31
+ }
32
+
33
+ /**
34
+ * @returns {string} The tokens joined with newlines.
35
+ */
36
+ toString() {
37
+ return this.tokens.join('\n');
38
+ }
39
+
40
+ /**
41
+ * Serializes the stream back to template source.
42
+ *
43
+ * Each token emits its retained trivia followed by its source text. Token
44
+ * values that were mutated are rendered from their current value, so
45
+ * mutated, inserted and removed tokens are all reflected. When nothing was
46
+ * mutated the result is byte-for-byte the source the stream was tokenized
47
+ * from. Parser-synthesised tokens are omitted.
48
+ *
49
+ * @returns {string} The serialized template source.
50
+ */
51
+ serialize() {
52
+ let out = '';
53
+ for (const token of this.tokens) {
54
+ if (token.synthetic) {
55
+ continue;
56
+ }
57
+ out += token.leading + token.serialize(this.options);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Inserts tokens at the current position.
64
+ *
65
+ * @param {Token[]} tokens Tokens to inject.
66
+ */
67
+ injectTokens(tokens) {
68
+ this.tokens = [
69
+ ...this.tokens.slice(0, this.current),
70
+ ...tokens,
71
+ ...this.tokens.slice(this.current),
72
+ ];
73
+ }
74
+
75
+ /**
76
+ * Sets the pointer to the next token and returns the old one.
77
+ *
78
+ * @returns {Token} The previous current token.
79
+ */
80
+ next() {
81
+ if (!this.tokens[++this.current]) {
82
+ throw new SyntaxError(
83
+ 'Unexpected end of template.',
84
+ this.tokens[this.current - 1].getLine(),
85
+ this.source,
86
+ );
87
+ }
88
+ return this.tokens[this.current - 1];
89
+ }
90
+
91
+ /**
92
+ * Tests a token, sets the pointer to the next one and returns it or null.
93
+ *
94
+ * @param {number|Array<string|number>|string} primary Type (or value) to test.
95
+ * @param {Array<string|number>|string|number|null} [secondary] Value(s) to test.
96
+ * @returns {Token|null} The next token if it matches, else null.
97
+ */
98
+ nextIf(primary, secondary = null) {
99
+ return this.tokens[this.current].test(primary, secondary)
100
+ ? this.next()
101
+ : null;
102
+ }
103
+
104
+ /**
105
+ * Tests a token and returns it or throws a {@link SyntaxError}.
106
+ *
107
+ * @param {number|Array<string|number>|string} type Type to expect.
108
+ * @param {Array<string|number>|string|number|null} [value] Expected value(s).
109
+ * @param {string|null} [message] Optional message prefix.
110
+ * @returns {Token} The expected token.
111
+ */
112
+ expect(type, value = null, message = null) {
113
+ const token = this.tokens[this.current];
114
+ if (!token.test(type, value)) {
115
+ const line = token.getLine();
116
+ throw new SyntaxError(
117
+ `${message ? `${message}. ` : ''}Unexpected token "${token.toEnglish()}"${
118
+ token.getValue() ? ` of value "${token.getValue()}"` : ''
119
+ } ("${typeToEnglish(/** @type {number} */ (type))}" expected${
120
+ value ? ` with value "${value}"` : ''
121
+ }).`,
122
+ line,
123
+ this.source,
124
+ this.source ? this.source.getColumn(token.getOffset() ?? -1) : null,
125
+ );
126
+ }
127
+ this.next();
128
+ return token;
129
+ }
130
+
131
+ /**
132
+ * Looks at the token `number` steps ahead without moving the pointer.
133
+ *
134
+ * @param {number} [number] How many tokens ahead (>= 1).
135
+ * @returns {Token} The looked-ahead token.
136
+ */
137
+ look(number = 1) {
138
+ if (!this.tokens[this.current + number]) {
139
+ throw new SyntaxError(
140
+ 'Unexpected end of template.',
141
+ this.tokens[this.current + number - 1].getLine(),
142
+ this.source,
143
+ );
144
+ }
145
+ return this.tokens[this.current + number];
146
+ }
147
+
148
+ /**
149
+ * Tests the current token.
150
+ *
151
+ * @param {number|Array<string|number>|string} primary
152
+ * @param {Array<string|number>|string|number|null} [secondary]
153
+ * @returns {boolean} Whether the current token matches.
154
+ */
155
+ test(primary, secondary = null) {
156
+ return this.tokens[this.current].test(primary, secondary);
157
+ }
158
+
159
+ /**
160
+ * @returns {boolean} Whether the end of the stream was reached.
161
+ */
162
+ isEOF() {
163
+ return this.tokens[this.current].test(TokenType.EOF);
164
+ }
165
+
166
+ /**
167
+ * @returns {Token} The current token.
168
+ */
169
+ getCurrent() {
170
+ return this.tokens[this.current];
171
+ }
172
+
173
+ /**
174
+ * @returns {import('./lexer.js').Source|null} The source context.
175
+ */
176
+ getSourceContext() {
177
+ return this.source;
178
+ }
179
+
180
+ /**
181
+ * @returns {Token[]} The underlying token array (used by the parser).
182
+ */
183
+ getTokens() {
184
+ return this.tokens;
185
+ }
186
+ }
187
+
188
+ export { TokenType };
package/src/token.js ADDED
@@ -0,0 +1,356 @@
1
+ // @ts-check
2
+ /**
3
+ * Token type constants and the {@link Token} class.
4
+ *
5
+ * Mirrors `src/Token.php` of the reference implementation.
6
+ *
7
+ * @module twig-tokenizer
8
+ */
9
+
10
+ /**
11
+ * Enumeration of token types. Values match the PHP reference implementation
12
+ * (`Twig\Token::*_TYPE`).
13
+ *
14
+ * @readonly
15
+ * @enum {number}
16
+ */
17
+ export const TokenType = {
18
+ /** End of the template stream. */
19
+ EOF: -1,
20
+ /** Static template text. */
21
+ TEXT: 0,
22
+ /** `{%` opening delimiter of a block tag. */
23
+ BLOCK_START: 1,
24
+ /** `{{` opening delimiter of a print statement. */
25
+ VAR_START: 2,
26
+ /** `%}` closing delimiter of a block tag. */
27
+ BLOCK_END: 3,
28
+ /** `}}` closing delimiter of a print statement. */
29
+ VAR_END: 4,
30
+ /** An identifier or keyword. */
31
+ NAME: 5,
32
+ /** An integer or floating-point literal. */
33
+ NUMBER: 6,
34
+ /** A quoted string fragment. */
35
+ STRING: 7,
36
+ /** An operator symbol or word. */
37
+ OPERATOR: 8,
38
+ /** One of `) ] { } : ,`. */
39
+ PUNCTUATION: 9,
40
+ /** `#{` inside a double-quoted string. */
41
+ INTERPOLATION_START: 10,
42
+ /** `}` closing an interpolation. */
43
+ INTERPOLATION_END: 11,
44
+ };
45
+
46
+ /** @typedef {number} TokenTypeValue */
47
+
48
+ /**
49
+ * A lexed token: a type, a value and source position information.
50
+ *
51
+ * @template {unknown} [T=unknown]
52
+ */
53
+ export class Token {
54
+ /**
55
+ * @param {TokenTypeValue} type The token type (one of {@link TokenType}).
56
+ * @param {T} value The token value.
57
+ * @param {number} lineNumber The 1-based line number.
58
+ * @param {number|null} [offset] The 0-based offset in the source (byte/char).
59
+ * @param {string|null} [documentation] Documentation comment attached to this token.
60
+ */
61
+ constructor(type, value, lineNumber, offset = null, documentation = null) {
62
+ /** @type {TokenTypeValue} */
63
+ this.type = type;
64
+ /** @type {T} */
65
+ this.value = value;
66
+ /** @type {number} */
67
+ this.lineNumber = lineNumber;
68
+ /** @type {number|null} */
69
+ this.offset = offset;
70
+ /** @type {string|null} */
71
+ this.documentation = documentation;
72
+
73
+ /** The exact source text of this token (null when synthetic). */
74
+ this.raw = null;
75
+ /** Trivia (whitespace/comments/delimiters) preceding this token. */
76
+ this.leading = '';
77
+ /** Absolute source offset where {@link Token#leading} starts. */
78
+ this.sourceStart = offset ?? 0;
79
+ /** Absolute source offset where {@link Token#raw} starts. */
80
+ this.rawStart = offset ?? 0;
81
+ /** Absolute source offset just past {@link Token#raw}. */
82
+ this.rawEnd = offset ?? 0;
83
+ /** Whether this token was synthesised by the parser and has no source. */
84
+ this.synthetic = false;
85
+ /** The value at construction time, used to detect mutations. */
86
+ this.originalValue = value;
87
+ }
88
+
89
+ /**
90
+ * Records the source text and trivia of this token.
91
+ *
92
+ * @param {string} raw The exact source text of the token (may be empty).
93
+ * @param {string} leading Trivia preceding the token.
94
+ * @param {number} sourceStart Absolute offset where the trivia starts.
95
+ * @returns {this} This token (for chaining).
96
+ */
97
+ withSource(raw, leading, sourceStart) {
98
+ this.raw = raw;
99
+ this.leading = leading;
100
+ this.sourceStart = sourceStart;
101
+ this.rawStart = sourceStart + leading.length;
102
+ this.rawEnd = this.rawStart + raw.length;
103
+ this.originalValue = this.value;
104
+ return this;
105
+ }
106
+
107
+ /**
108
+ * Whether the token's value differs from the value it was created with.
109
+ *
110
+ * @returns {boolean} True when the value was mutated.
111
+ */
112
+ isMutated() {
113
+ return this.value !== this.originalValue;
114
+ }
115
+
116
+ /**
117
+ * Tests the token for a type and/or value.
118
+ *
119
+ * Parameters may be:
120
+ * * just type
121
+ * * type and value (or array of possible values)
122
+ * * just value (or array of possible values); NAME type is used then.
123
+ *
124
+ * For backwards compatibility, an OPERATOR expectation also matches a
125
+ * PUNCTUATION token whose value is one of `(`, `[`, `|`, `.`, `?`, `?:`.
126
+ *
127
+ * @param {TokenTypeValue|Array<string|number>|string} type The type to test.
128
+ * @param {Array<string|number>|string|number|null} [values] The token value(s).
129
+ * @returns {boolean} Whether the token matches.
130
+ */
131
+ test(type, values = null) {
132
+ let t = type;
133
+ let v = values;
134
+ if (v === null && typeof t !== 'number') {
135
+ v = t;
136
+ t = TokenType.NAME;
137
+ }
138
+
139
+ let typeMatches = this.type === t;
140
+ if (typeMatches && t === TokenType.PUNCTUATION && Array.isArray(v)) {
141
+ const legacy = ['(', '[', '|', '.', '?', '?:'];
142
+ if (v.some((x) => legacy.includes(String(x)))) {
143
+ // punctuation that is now an operator token; keep matching
144
+ }
145
+ }
146
+ if (!typeMatches && t === TokenType.OPERATOR && this.type === TokenType.PUNCTUATION) {
147
+ if (v === null) {
148
+ typeMatches = true;
149
+ } else if (Array.isArray(v)) {
150
+ typeMatches = v.some((x) => ['(', '[', '|', '.', '?', '?:'].includes(String(x)));
151
+ } else {
152
+ typeMatches = ['(', '[', '|', '.', '?', '?:'].includes(String(v));
153
+ }
154
+ }
155
+
156
+ return (
157
+ typeMatches &&
158
+ (v === null ||
159
+ (Array.isArray(v) && v.includes(/** @type {string|number} */ (this.value))) ||
160
+ // eslint-disable-next-line eqeqeq -- intentionally compares string and number token values
161
+ this.value == v)
162
+ );
163
+ }
164
+
165
+ /**
166
+ * @returns {number} The 1-based line number of the token.
167
+ */
168
+ getLine() {
169
+ return this.lineNumber;
170
+ }
171
+
172
+ /**
173
+ * @returns {T} The token value.
174
+ */
175
+ getValue() {
176
+ return this.value;
177
+ }
178
+
179
+ /**
180
+ * @returns {TokenTypeValue} The token type.
181
+ */
182
+ getType() {
183
+ return this.type;
184
+ }
185
+
186
+ /**
187
+ * @returns {number|null} The 0-based offset, or null when not tied to a position.
188
+ */
189
+ getOffset() {
190
+ return this.offset;
191
+ }
192
+
193
+ /**
194
+ * @returns {string|null} Attached documentation, if any.
195
+ */
196
+ getDocumentation() {
197
+ return this.documentation;
198
+ }
199
+
200
+ /**
201
+ * @returns {string} An English description of the token type.
202
+ */
203
+ toEnglish() {
204
+ return typeToEnglish(this.type);
205
+ }
206
+
207
+ /**
208
+ * @returns {string} `TYPE(value)` representation, e.g. `NAME_TYPE(foo)`.
209
+ */
210
+ toString() {
211
+ return `${typeToString(this.type, true)}(${this.value})`;
212
+ }
213
+
214
+ /**
215
+ * Serializes this token back to template source.
216
+ *
217
+ * When the token still holds the value it was lexed with, its exact original
218
+ * source text is returned (lossless). When the value was mutated, the token
219
+ * is rendered from its current value instead, so mutations are reflected.
220
+ * Parser-synthesised tokens serialize to the empty string.
221
+ *
222
+ * @param {import('./lexer.js').LexerOptions|null} [options] Lexer options
223
+ * (used to render mutated delimiters).
224
+ * @returns {string} The source text of the token.
225
+ */
226
+ serialize(options = null) {
227
+ if (this.synthetic) {
228
+ return '';
229
+ }
230
+ if (this.raw !== null && !this.isMutated()) {
231
+ return this.raw;
232
+ }
233
+ return this.render(options);
234
+ }
235
+
236
+ /**
237
+ * Renders a canonical source representation from the current type/value.
238
+ *
239
+ * @param {import('./lexer.js').LexerOptions|null} [options] Lexer options.
240
+ * @returns {string} The rendered token text.
241
+ */
242
+ render(options = null) {
243
+ const tagBlock = options?.tag_block ?? ['{%', '%}'];
244
+ const tagVariable = options?.tag_variable ?? ['{{', '}}'];
245
+ const interpolation = options?.interpolation ?? ['#{', '}'];
246
+ switch (this.type) {
247
+ case TokenType.TEXT:
248
+ case TokenType.NAME:
249
+ case TokenType.OPERATOR:
250
+ case TokenType.PUNCTUATION:
251
+ return String(this.value);
252
+ case TokenType.NUMBER:
253
+ return formatNumber(this.value);
254
+ case TokenType.STRING:
255
+ return quoteString(this.value);
256
+ case TokenType.BLOCK_START:
257
+ return tagBlock[0];
258
+ case TokenType.BLOCK_END:
259
+ return tagBlock[1];
260
+ case TokenType.VAR_START:
261
+ return tagVariable[0];
262
+ case TokenType.VAR_END:
263
+ return tagVariable[1];
264
+ case TokenType.INTERPOLATION_START:
265
+ return interpolation[0];
266
+ case TokenType.INTERPOLATION_END:
267
+ return interpolation[1];
268
+ case TokenType.EOF:
269
+ return '';
270
+ default:
271
+ return String(this.value);
272
+ }
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Renders a number as it would appear in Twig source.
278
+ *
279
+ * @param {unknown} value The number value.
280
+ * @returns {string} The rendered number.
281
+ */
282
+ function formatNumber(value) {
283
+ const numeric = Number(value);
284
+ if (Number.isNaN(numeric)) {
285
+ return '0';
286
+ }
287
+ return String(numeric);
288
+ }
289
+
290
+ /**
291
+ * Renders a string as a single-quoted Twig literal.
292
+ *
293
+ * @param {unknown} value The string value.
294
+ * @returns {string} The quoted literal.
295
+ */
296
+ function quoteString(value) {
297
+ const text = String(value);
298
+ return `'${text.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
299
+ }
300
+
301
+ /**
302
+ * Converts a token type value to its constant name.
303
+ *
304
+ * @param {TokenTypeValue} type
305
+ * @param {boolean} [short] When true, omit the `Twig\Token::` prefix.
306
+ * @returns {string} The type name.
307
+ */
308
+ export function typeToString(type, short = false) {
309
+ /** @type {Record<number, string>} */
310
+ const names = {
311
+ [TokenType.EOF]: 'EOF_TYPE',
312
+ [TokenType.TEXT]: 'TEXT_TYPE',
313
+ [TokenType.BLOCK_START]: 'BLOCK_START_TYPE',
314
+ [TokenType.VAR_START]: 'VAR_START_TYPE',
315
+ [TokenType.BLOCK_END]: 'BLOCK_END_TYPE',
316
+ [TokenType.VAR_END]: 'VAR_END_TYPE',
317
+ [TokenType.NAME]: 'NAME_TYPE',
318
+ [TokenType.NUMBER]: 'NUMBER_TYPE',
319
+ [TokenType.STRING]: 'STRING_TYPE',
320
+ [TokenType.OPERATOR]: 'OPERATOR_TYPE',
321
+ [TokenType.PUNCTUATION]: 'PUNCTUATION_TYPE',
322
+ [TokenType.INTERPOLATION_START]: 'INTERPOLATION_START_TYPE',
323
+ [TokenType.INTERPOLATION_END]: 'INTERPOLATION_END_TYPE',
324
+ };
325
+ const name = names[type];
326
+ if (name === undefined) {
327
+ throw new Error(`Token of type "${type}" does not exist.`);
328
+ }
329
+ return short ? name : `Twig\\Token::${name}`;
330
+ }
331
+
332
+ /**
333
+ * Converts a token type value to an English description.
334
+ *
335
+ * @param {TokenTypeValue} type
336
+ * @returns {string} English description.
337
+ */
338
+ export function typeToEnglish(type) {
339
+ /** @type {Record<number, string>} */
340
+ const names = {
341
+ [TokenType.EOF]: 'end of template',
342
+ [TokenType.TEXT]: 'text',
343
+ [TokenType.BLOCK_START]: 'begin of statement block',
344
+ [TokenType.VAR_START]: 'begin of print statement',
345
+ [TokenType.BLOCK_END]: 'end of statement block',
346
+ [TokenType.VAR_END]: 'end of print statement',
347
+ [TokenType.NAME]: 'name',
348
+ [TokenType.NUMBER]: 'number',
349
+ [TokenType.STRING]: 'string',
350
+ [TokenType.OPERATOR]: 'operator',
351
+ [TokenType.PUNCTUATION]: 'punctuation',
352
+ [TokenType.INTERPOLATION_START]: 'begin of string interpolation',
353
+ [TokenType.INTERPOLATION_END]: 'end of string interpolation',
354
+ };
355
+ return names[type] ?? `token of type ${type}`;
356
+ }