@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 ADDED
@@ -0,0 +1,24 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mr. Henry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ This license applies only to the code in this repository.
24
+ Images are explicitly excluded.
package/package.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@mrhenry/twig-tokenizer",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ }
10
+ }
@@ -0,0 +1,54 @@
1
+ // @ts-check
2
+ /**
3
+ * Named constants for the code points used by the Twig lexer.
4
+ *
5
+ * @module twig-tokenizer
6
+ */
7
+
8
+ /** \x00 */
9
+ export const NULL = 0x0000;
10
+ /** \t */
11
+ export const CHARACTER_TABULATION = 0x0009;
12
+ /** \n */
13
+ export const LINE_FEED = 0x000a;
14
+ /** \v */
15
+ export const LINE_TABULATION = 0x000b;
16
+ /** \f */
17
+ export const FORM_FEED = 0x000c;
18
+ /** \r */
19
+ export const CARRIAGE_RETURN = 0x000d;
20
+ /** \u20 */
21
+ export const SPACE = 0x0020;
22
+
23
+ /** " */
24
+ export const QUOTATION_MARK = 0x0022;
25
+ /** # */
26
+ export const NUMBER_SIGN = 0x0023;
27
+ /** ' */
28
+ export const APOSTROPHE = 0x0027;
29
+ /** ( */
30
+ export const LEFT_PARENTHESIS = 0x0028;
31
+ /** ) */
32
+ export const RIGHT_PARENTHESIS = 0x0029;
33
+ /** + */
34
+ export const PLUS_SIGN = 0x002b;
35
+ /** - */
36
+ export const HYPHEN_MINUS = 0x002d;
37
+ /** . */
38
+ export const FULL_STOP = 0x002e;
39
+ /** E */
40
+ export const LATIN_CAPITAL_LETTER_E = 0x0045;
41
+ /** [ */
42
+ export const LEFT_SQUARE_BRACKET = 0x005b;
43
+ /** \ */
44
+ export const REVERSE_SOLIDUS = 0x005c;
45
+ /** _ */
46
+ export const LOW_LINE = 0x005f;
47
+ /** e */
48
+ export const LATIN_SMALL_LETTER_E = 0x0065;
49
+ /** { */
50
+ export const LEFT_CURLY_BRACKET = 0x007b;
51
+ /** | */
52
+ export const VERTICAL_LINE = 0x007c;
53
+ /** } */
54
+ export const RIGHT_CURLY_BRACKET = 0x007d;
@@ -0,0 +1,161 @@
1
+ // @ts-check
2
+ /**
3
+ * Predicates for classifying code points during lexing.
4
+ *
5
+ * @module twig-tokenizer
6
+ */
7
+ import {
8
+ CARRIAGE_RETURN,
9
+ CHARACTER_TABULATION,
10
+ FORM_FEED,
11
+ LEFT_CURLY_BRACKET,
12
+ LEFT_PARENTHESIS,
13
+ LEFT_SQUARE_BRACKET,
14
+ LINE_FEED,
15
+ LINE_TABULATION,
16
+ NULL,
17
+ RIGHT_PARENTHESIS,
18
+ SPACE,
19
+ } from './code-points.js';
20
+
21
+ /**
22
+ * Whether the code point is an ASCII decimal digit (`0` .. `9`).
23
+ *
24
+ * @param {number} search The code point to test.
25
+ * @returns {boolean}
26
+ */
27
+ export function isDigitCodePoint(search) {
28
+ return search >= 0x0030 && search <= 0x0039;
29
+ }
30
+
31
+ /**
32
+ * Whether the code point is a hexadecimal digit (`0` .. `9`, `a` .. `f`, `A` .. `F`).
33
+ *
34
+ * @param {number} search The code point to test.
35
+ * @returns {boolean}
36
+ */
37
+ export function isHexDigitCodePoint(search) {
38
+ return (
39
+ (search >= 0x0030 && search <= 0x0039) ||
40
+ (search >= 0x0061 && search <= 0x0066) ||
41
+ (search >= 0x0041 && search <= 0x0046)
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Whether the code point is an octal digit (`0` .. `7`).
47
+ *
48
+ * @param {number} search The code point to test.
49
+ * @returns {boolean}
50
+ */
51
+ export function isOctalDigitCodePoint(search) {
52
+ return search >= 0x0030 && search <= 0x0037;
53
+ }
54
+
55
+ /**
56
+ * Whether the code point is an ASCII letter (`a` .. `z`, `A` .. `Z`).
57
+ *
58
+ * @param {number} search The code point to test.
59
+ * @returns {boolean}
60
+ */
61
+ export function isAsciiLetterCodePoint(search) {
62
+ return (
63
+ (search >= 0x0061 && search <= 0x007a) ||
64
+ (search >= 0x0041 && search <= 0x005a)
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Whether the code point starts a Twig name.
70
+ *
71
+ * Mirrors the reference implementation's byte-oriented
72
+ * `[a-zA-Z_\x7f-\xff]`: in byte mode any UTF-8 byte in `\x7f-\xff` is an
73
+ * identifier start character, which corresponds to every code point from
74
+ * U+007F onward here (astral code points are matched via their surrogate
75
+ * halves in byte terms, and via the code point itself here).
76
+ *
77
+ * @param {number} search The code point to test.
78
+ * @returns {boolean}
79
+ */
80
+ export function isNameStartCodePoint(search) {
81
+ return (
82
+ (search >= 0x0041 && search <= 0x005a) ||
83
+ (search >= 0x0061 && search <= 0x007a) ||
84
+ search === 0x005f ||
85
+ search >= 0x007f
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Whether the code point continues a Twig name.
91
+ *
92
+ * Mirrors the byte-oriented `[a-zA-Z0-9_\x7f-\xff]`.
93
+ *
94
+ * @param {number} search The code point to test.
95
+ * @returns {boolean}
96
+ */
97
+ export function isNameCodePoint(search) {
98
+ return isNameStartCodePoint(search) || isDigitCodePoint(search);
99
+ }
100
+
101
+ /**
102
+ * Whether the code point is whitespace as matched by PCRE `\s` in byte mode.
103
+ *
104
+ * Space, tab, line feed, vertical tab, form feed and carriage return. JS
105
+ * `\s` also matches Unicode whitespace (NBSP, en/em spaces, U+2028/U+2029, …)
106
+ * that the reference implementation treats as identifier characters in byte
107
+ * mode; using this explicit set keeps the lexer byte-for-byte compatible
108
+ * with `src/Lexer.php`.
109
+ *
110
+ * @param {number} search The code point to test.
111
+ * @returns {boolean}
112
+ */
113
+ export function isWhitespaceCodePoint(search) {
114
+ return (
115
+ search === SPACE ||
116
+ search === CHARACTER_TABULATION ||
117
+ search === LINE_FEED ||
118
+ search === LINE_TABULATION ||
119
+ search === FORM_FEED ||
120
+ search === CARRIAGE_RETURN
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Whether the code point is stripped by PHP's default `ltrim()`/`rtrim()`.
126
+ *
127
+ * The default character list is `" \t\n\r\v\x00"`, which notably does *not*
128
+ * include the form feed `\f`.
129
+ *
130
+ * @param {number} search The code point to test.
131
+ * @returns {boolean}
132
+ */
133
+ export function isPhpTrimCodePoint(search) {
134
+ return (
135
+ search === SPACE ||
136
+ search === CHARACTER_TABULATION ||
137
+ search === LINE_FEED ||
138
+ search === CARRIAGE_RETURN ||
139
+ search === LINE_TABULATION ||
140
+ search === NULL
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Whether the code point may follow an operator that ends with a letter.
146
+ *
147
+ * Mirrors the `(?=[\s()\[{])` lookahead of the reference implementation.
148
+ *
149
+ * @param {number} search The code point to test.
150
+ * @returns {boolean}
151
+ */
152
+ export function isOperatorDelimiterCodePoint(search) {
153
+ return (
154
+ isWhitespaceCodePoint(search) ||
155
+ search === LEFT_PARENTHESIS ||
156
+ search === RIGHT_PARENTHESIS ||
157
+ search === LEFT_SQUARE_BRACKET ||
158
+ search === LEFT_CURLY_BRACKET
159
+ );
160
+ }
161
+
package/src/errors.js ADDED
@@ -0,0 +1,283 @@
1
+ // @ts-check
2
+ /**
3
+ * Error classes mirroring `Twig\Error\*`.
4
+ *
5
+ * @module twig-tokenizer
6
+ */
7
+
8
+ /**
9
+ * A source context (template name + code).
10
+ *
11
+ * @typedef {object} Source
12
+ * @property {string} name The template name.
13
+ * @property {string} code The template source code.
14
+ */
15
+
16
+ /**
17
+ * Base class for all Twig errors.
18
+ *
19
+ * Mirrors `Twig\Error\Error`: the raw message is stored separately and the
20
+ * exposed message appends the template location (`in "name" at line N column M.`).
21
+ */
22
+ export class TwigError extends Error {
23
+ /**
24
+ * @param {string} message The raw error message.
25
+ * @param {number} [lineNumber] The 1-based template line, or -1.
26
+ * @param {Source|null} [source] The template source context.
27
+ * @param {Error|null} [previous] The previous (wrapped) exception.
28
+ * @param {number|null} [columnNumber] The 1-based column, if known.
29
+ */
30
+ constructor(message, lineNumber = -1, source = null, previous = null, columnNumber = null) {
31
+ super(message);
32
+ this.name = this.constructor.name;
33
+ /** @type {number} */
34
+ this.lineNumber = lineNumber;
35
+ /** @type {Source|null} */
36
+ this.source = source;
37
+ /** @type {Error|null} */
38
+ this.previous = previous;
39
+ /** @type {number|null} */
40
+ this.columnNumber = columnNumber;
41
+ /** @type {string} */
42
+ this.rawMessage = message;
43
+ this.updateRepresentation();
44
+ }
45
+
46
+ /** Rebuilds the formatted message with template location operatorInfo. */
47
+ updateRepresentation() {
48
+ let message = this.rawMessage;
49
+ const last = message.slice(-1);
50
+ const punctuation = last === '.' || last === '?' ? last : '';
51
+ if (punctuation) {
52
+ message = message.slice(0, -1);
53
+ }
54
+ if (this.source && this.source.name) {
55
+ message += ` in "${this.source.name}"`;
56
+ }
57
+ if (this.lineNumber > 0) {
58
+ message += ` at line ${this.lineNumber}`;
59
+ if (this.columnNumber !== null) {
60
+ message += ` column ${this.columnNumber}`;
61
+ }
62
+ }
63
+ if (punctuation) {
64
+ message += punctuation;
65
+ }
66
+ this.message = message;
67
+ }
68
+
69
+ /**
70
+ * @returns {string} The raw message (without location).
71
+ */
72
+ getRawMessage() {
73
+ return this.rawMessage;
74
+ }
75
+
76
+ /**
77
+ * The fully-qualified PHP class name this error maps to.
78
+ *
79
+ * @returns {string} e.g. `Twig\Error\SyntaxError`.
80
+ */
81
+ getPhpClass() {
82
+ return `Twig\\Error\\${this.name.replace(/Error$/, 'Error')}`;
83
+ }
84
+
85
+ /**
86
+ * Formats the error like PHP's `sprintf('%s: %s', $e::class, $e->getMessage())`.
87
+ *
88
+ * @returns {string} `Class: message`.
89
+ */
90
+ toPhpFormat() {
91
+ return `${this.getPhpClass()}: ${this.message}`;
92
+ }
93
+
94
+ /**
95
+ * @returns {number} The template line, or -1.
96
+ */
97
+ getTemplateLine() {
98
+ return this.lineNumber;
99
+ }
100
+
101
+ /**
102
+ * Appends text to the raw message and rebuilds the formatted message.
103
+ *
104
+ * Mirrors `Twig\Error\Error::appendMessage()`.
105
+ *
106
+ * @param {string} rawMessage
107
+ */
108
+ appendMessage(rawMessage) {
109
+ this.rawMessage += rawMessage;
110
+ this.updateRepresentation();
111
+ }
112
+
113
+ /**
114
+ * @param {number} line Sets the template line.
115
+ */
116
+ setTemplateLine(line) {
117
+ this.lineNumber = line;
118
+ this.updateRepresentation();
119
+ }
120
+
121
+ /**
122
+ * @returns {Source|null} The source context.
123
+ */
124
+ getSourceContext() {
125
+ return this.source;
126
+ }
127
+
128
+ /**
129
+ * @param {Source|null} source Sets the source context.
130
+ */
131
+ setSourceContext(source) {
132
+ this.source = source;
133
+ this.updateRepresentation();
134
+ }
135
+
136
+ /**
137
+ * @param {number|null} columnNumber Sets the column.
138
+ */
139
+ setTemplateColumn(columnNumber) {
140
+ this.columnNumber = columnNumber;
141
+ this.updateRepresentation();
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Raised for lexer/parser errors, unknown tags or callables.
147
+ */
148
+ export class SyntaxError extends TwigError {
149
+ /**
150
+ * @param {string} message
151
+ * @param {number} [lineNumber]
152
+ * @param {Source|null} [source]
153
+ * @param {number|null} [columnNumber]
154
+ */
155
+ constructor(message, lineNumber = -1, source = null, columnNumber = null) {
156
+ super(message, lineNumber, source, null, columnNumber);
157
+ }
158
+
159
+ /**
160
+ * @override
161
+ * @returns {string}
162
+ */
163
+ getPhpClass() {
164
+ return 'Twig\\Error\\SyntaxError';
165
+ }
166
+
167
+ /**
168
+ * Appends "Did you mean …?" suggestions to the message.
169
+ *
170
+ * Mirrors `Twig\Error\SyntaxError::addSuggestions()`, including its
171
+ * byte-oriented semantics: the Levenshtein distance and the length threshold
172
+ * are computed over the UTF-8 byte sequences (PHP `levenshtein()` /
173
+ * `strlen()`).
174
+ *
175
+ * @param {string} name The name that does not exist.
176
+ * @param {string[]} items The possible names.
177
+ */
178
+ addSuggestions(name, items) {
179
+ const nameBytes = toBytes(name);
180
+ /** @type {Record<string, number>} */
181
+ const alternatives = {};
182
+ for (const item of items) {
183
+ const lev = byteLevenshtein(nameBytes, toBytes(item));
184
+ if (lev <= nameBytes.length / 3 || item.includes(name)) {
185
+ alternatives[item] = lev;
186
+ }
187
+ }
188
+ const keys = Object.keys(alternatives);
189
+ if (!keys.length) {
190
+ return;
191
+ }
192
+ keys.sort((a, b) => alternatives[a] - alternatives[b]);
193
+ this.appendMessage(` Did you mean "${keys.join('", "')}"?`);
194
+ }
195
+ }
196
+
197
+ /**
198
+ * @param {string} value
199
+ * @returns {Uint8Array} The UTF-8 bytes of the string.
200
+ */
201
+ function toBytes(value) {
202
+ return new TextEncoder().encode(value);
203
+ }
204
+
205
+ /**
206
+ * The Levenshtein distance between two byte sequences (mirrors PHP
207
+ * `levenshtein()`, which operates on bytes).
208
+ *
209
+ * @param {Uint8Array} a
210
+ * @param {Uint8Array} b
211
+ * @returns {number}
212
+ */
213
+ function byteLevenshtein(a, b) {
214
+ if (a.length === 0) {
215
+ return b.length;
216
+ }
217
+ if (b.length === 0) {
218
+ return a.length;
219
+ }
220
+ const matrix = new Uint16Array((a.length + 1) * (b.length + 1));
221
+ for (let i = 0; i <= a.length; i++) {
222
+ matrix[i * (b.length + 1)] = i;
223
+ }
224
+ for (let j = 0; j <= b.length; j++) {
225
+ matrix[j] = j;
226
+ }
227
+ for (let i = 1; i <= a.length; i++) {
228
+ for (let j = 1; j <= b.length; j++) {
229
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
230
+ matrix[i * (b.length + 1) + j] = Math.min(
231
+ matrix[(i - 1) * (b.length + 1) + j] + 1,
232
+ matrix[i * (b.length + 1) + j - 1] + 1,
233
+ matrix[(i - 1) * (b.length + 1) + j - 1] + cost,
234
+ );
235
+ }
236
+ }
237
+ return matrix[a.length * (b.length + 1) + b.length];
238
+ }
239
+
240
+ /**
241
+ * Raised for errors during rendering.
242
+ */
243
+ export class RuntimeError extends TwigError {
244
+ /**
245
+ * @param {string} message
246
+ * @param {number} [lineNumber]
247
+ * @param {Source|null} [source]
248
+ * @param {Error|null} [previous]
249
+ */
250
+ constructor(message, lineNumber = -1, source = null, previous = null) {
251
+ super(message, lineNumber, source, previous);
252
+ }
253
+
254
+ /**
255
+ * @override
256
+ * @returns {string}
257
+ */
258
+ getPhpClass() {
259
+ return 'Twig\\Error\\RuntimeError';
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Raised when a template cannot be found or read.
265
+ */
266
+ export class LoaderError extends TwigError {
267
+ /**
268
+ * @param {string} message
269
+ * @param {number} [lineNumber]
270
+ * @param {Source|null} [source]
271
+ */
272
+ constructor(message, lineNumber = -1, source = null) {
273
+ super(message, lineNumber, source);
274
+ }
275
+
276
+ /**
277
+ * @override
278
+ * @returns {string}
279
+ */
280
+ getPhpClass() {
281
+ return 'Twig\\Error\\LoaderError';
282
+ }
283
+ }
package/src/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // @ts-check
2
+ /**
3
+ * Public API of the `@mrhenry/twig-tokenizer` package.
4
+ *
5
+ * @module twig-tokenizer
6
+ */
7
+
8
+ export { Token, TokenType, typeToString, typeToEnglish } from './token.js';
9
+ export { TokenStream } from './token-stream.js';
10
+ export { Lexer, LexerState, Source, DEFAULT_LEXER_OPTIONS } from './lexer.js';
11
+ export { TwigError, SyntaxError, RuntimeError, LoaderError } from './errors.js';