driftjs-compiler 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lexer.ts DELETED
@@ -1,954 +0,0 @@
1
- import type {
2
- Token,
3
- SourceLocation,
4
- DriftLexerState,
5
- LexerStateTransition,
6
- RawTextTagName,
7
- } from '../types/index.js';
8
- import {
9
- TokenType,
10
- DriftLexerError,
11
- LexerStateKind,
12
- } from '../types/index.js';
13
-
14
- /**
15
- * Canonical lexer transition rules for Drift templates.
16
- *
17
- * The lexer is parser-driven and emits one token per request. These rules make
18
- * every legal transition explicit so the lexer can validate its own state
19
- * changes while scanning Drift templates.
20
- */
21
- export const LEXER_STATE_TRANSITIONS: {
22
- readonly [K in LexerStateKind]: readonly LexerStateTransition[];
23
- } = {
24
- [LexerStateKind.Data]: [
25
- { to: LexerStateKind.Comment, when: 'The next characters are <!--', emits: 'Comment' },
26
- { to: LexerStateKind.EndTagOpen, when: 'The next characters are </', emits: 'TagOpenSlash' },
27
- { to: LexerStateKind.TagOpen, when: "The next character is '<' for a start tag", emits: 'TagOpen' },
28
- { to: LexerStateKind.Interpolation, when: "The next character is '{' in element content", emits: 'Interpolation' },
29
- { to: LexerStateKind.Data, when: 'Plain content is consumed up to the next control delimiter', emits: 'Text' },
30
- { to: LexerStateKind.EOF, when: 'The source has been fully consumed', emits: 'EOF' },
31
- ],
32
- [LexerStateKind.TagOpen]: [
33
- { to: LexerStateKind.BeforeAttributeName, when: 'A valid opening tag name is consumed', emits: 'Identifier' },
34
- ],
35
- [LexerStateKind.EndTagOpen]: [
36
- { to: LexerStateKind.BeforeAttributeName, when: 'A valid closing tag name is consumed', emits: 'Identifier' },
37
- ],
38
- [LexerStateKind.BeforeAttributeName]: [
39
- { to: LexerStateKind.AttributeName, when: 'An attribute name begins in an opening tag', emits: 'Identifier' },
40
- { to: LexerStateKind.Data, when: "A tag is closed with '>' and raw-text mode does not apply", emits: 'TagClose' },
41
- { to: LexerStateKind.RawText, when: "A script/style start tag is closed with '>'", emits: 'TagClose' },
42
- { to: LexerStateKind.Data, when: "A tag is self-closed with '/>'", emits: 'TagSelfClose' },
43
- ],
44
- [LexerStateKind.AttributeName]: [
45
- { to: LexerStateKind.AfterAttributeName, when: 'The attribute identifier has been fully consumed', emits: 'Identifier' },
46
- ],
47
- [LexerStateKind.AfterAttributeName]: [
48
- { to: LexerStateKind.BeforeAttributeValue, when: "The next character is '='", emits: 'Equals' },
49
- { to: LexerStateKind.BeforeAttributeName, when: 'The attribute is boolean and the lexer moves to the next attribute or tag boundary', emits: 'No token' },
50
- ],
51
- [LexerStateKind.BeforeAttributeValue]: [
52
- { to: LexerStateKind.AttributeValueQuoted, when: 'The attribute value starts with a quote', emits: 'StringLiteral' },
53
- { to: LexerStateKind.AttributeValueInterpolation, when: "The attribute value starts with '{'", emits: 'Interpolation' },
54
- ],
55
- [LexerStateKind.AttributeValueQuoted]: [
56
- { to: LexerStateKind.BeforeAttributeName, when: 'The closing quote is consumed', emits: 'StringLiteral' },
57
- ],
58
- [LexerStateKind.AttributeValueInterpolation]: [
59
- { to: LexerStateKind.BeforeAttributeName, when: 'The interpolation closes and the attribute value is complete', emits: 'Interpolation' },
60
- ],
61
- [LexerStateKind.Comment]: [
62
- { to: LexerStateKind.Data, when: 'The terminating --> delimiter is consumed', emits: 'Comment' },
63
- ],
64
- [LexerStateKind.Interpolation]: [
65
- { to: LexerStateKind.Data, when: 'A content interpolation closes with a matching brace', emits: 'Interpolation' },
66
- { to: LexerStateKind.BeforeAttributeName, when: 'An attribute interpolation closes with a matching brace', emits: 'Interpolation' },
67
- ],
68
- [LexerStateKind.RawText]: [
69
- { to: LexerStateKind.RawText, when: 'Plain raw-text content is consumed before the matching closing tag', emits: 'Text' },
70
- { to: LexerStateKind.EndTagOpen, when: 'The matching raw-text closing tag begins with </', emits: 'TagOpenSlash' },
71
- { to: LexerStateKind.EOF, when: 'The raw-text block reaches end of input before a closing tag appears', emits: 'EOF' },
72
- ],
73
- [LexerStateKind.EOF]: [
74
- { to: LexerStateKind.EOF, when: 'Additional parser requests are made after end of input', emits: 'EOF' },
75
- ],
76
- };
77
-
78
- export function isRawTextTagName(tagName: string): tagName is RawTextTagName {
79
- return tagName === 'script' || tagName === 'style';
80
- }
81
-
82
- const KNOWN_DIRECTIVES = new Set(['if', 'else', 'for', 'switch', 'case', 'default']);
83
-
84
-
85
- /**
86
- * Stateful on-demand lexer for Drift templates.
87
- *
88
- * The parser drives tokenization by requesting one token at a time through
89
- * `nextToken()`. The lexer keeps its current lexical state between calls so it
90
- * can preserve HTML/XML parsing context without materializing the full token
91
- * stream up front.
92
- */
93
- export class DriftLexer {
94
- private readonly source: string;
95
- private offset = 0;
96
- private line = 1;
97
- private column = 1;
98
- private emittedTokenCount = 0;
99
- private eofToken: Token | null = null;
100
- private state: DriftLexerState = { kind: LexerStateKind.Data };
101
- private blockDepth = 0;
102
-
103
- constructor(source: string) {
104
- this.source = source;
105
- }
106
-
107
- /**
108
- * Returns exactly one token for each parser request.
109
- */
110
- public nextToken(): Token {
111
- const token = this.readNextToken();
112
- this.emittedTokenCount++;
113
- return token;
114
- }
115
-
116
- public getCurrentState(): DriftLexerState {
117
- return this.state;
118
- }
119
-
120
- public getEmittedTokenCount(): number {
121
- return this.emittedTokenCount;
122
- }
123
-
124
- private readNextToken(): Token {
125
- switch (this.state.kind) {
126
- case LexerStateKind.Data:
127
- return this.readDataToken();
128
- case LexerStateKind.TagOpen:
129
- return this.readTagNameToken(false);
130
- case LexerStateKind.EndTagOpen:
131
- return this.readTagNameToken(true);
132
- case LexerStateKind.BeforeAttributeName:
133
- return this.readBeforeAttributeNameToken();
134
- case LexerStateKind.AttributeName:
135
- return this.readAttributeNameToken();
136
- case LexerStateKind.AfterAttributeName:
137
- return this.readAfterAttributeNameToken();
138
- case LexerStateKind.BeforeAttributeValue:
139
- return this.readBeforeAttributeValueToken();
140
- case LexerStateKind.Comment:
141
- case LexerStateKind.Interpolation:
142
- case LexerStateKind.AttributeValueQuoted:
143
- case LexerStateKind.AttributeValueInterpolation:
144
- throw new Error(`Lexer entered transient state '${this.state.kind}' unexpectedly.`);
145
- case LexerStateKind.RawText:
146
- return this.readRawTextToken();
147
- case LexerStateKind.EOF:
148
- return this.getOrCreateEOFToken();
149
- }
150
- }
151
-
152
- private readDataToken(): Token {
153
- const startLoc = this.getLocation();
154
-
155
- if (this.isAtEnd()) {
156
- this.transitionTo({ kind: LexerStateKind.EOF });
157
- return this.getOrCreateEOFToken(startLoc);
158
- }
159
-
160
- if (this.startsWith('<!--')) {
161
- this.consumePattern('<!--');
162
- this.transitionTo({ kind: LexerStateKind.Comment });
163
- return this.readCommentToken(startLoc);
164
- }
165
-
166
- if (this.startsWith('</')) {
167
- this.consumePattern('</');
168
- this.transitionTo({ kind: LexerStateKind.EndTagOpen });
169
- return this.createToken(TokenType.TagOpenSlash, '</', startLoc);
170
- }
171
-
172
- if (this.peek() === '<') {
173
- this.advance();
174
- this.transitionTo({ kind: LexerStateKind.TagOpen });
175
- return this.createToken(TokenType.TagOpen, '<', startLoc);
176
- }
177
-
178
- if (this.peek() === '{') {
179
- this.advance();
180
- this.transitionTo({
181
- kind: LexerStateKind.Interpolation,
182
- context: 'content',
183
- tagName: null,
184
- });
185
- return this.readInterpolationToken(startLoc, 'content', null);
186
- }
187
-
188
- if (this.peek() === '}' && this.blockDepth > 0) {
189
- this.advance();
190
- this.blockDepth--;
191
- return this.createToken(TokenType.BlockClose, '}', startLoc);
192
- }
193
-
194
- if (this.peek() === '@') {
195
- return this.readDirectiveToken(startLoc);
196
- }
197
-
198
- return this.readTextToken();
199
- }
200
-
201
- private readDirectiveToken(startLoc: SourceLocation): Token {
202
- this.advance(); // consume '@'
203
- let name = '';
204
- while (!this.isAtEnd() && /[a-zA-Z]/.test(this.peek())) {
205
- name += this.advance();
206
- }
207
- if (!KNOWN_DIRECTIVES.has(name)) {
208
- throw new DriftLexerError(
209
- `Unknown directive '@${name}'`,
210
- startLoc.line,
211
- startLoc.column,
212
- startLoc.offset
213
- );
214
- }
215
-
216
- if (name === 'if') {
217
- return this.readDirectiveHeader(startLoc, TokenType.DirectiveIf);
218
- } else if (name === 'else') {
219
- this.skipWhitespace();
220
- if (this.startsWith('if') && !/[a-zA-Z0-9_]/.test(this.peek(2))) {
221
- this.consumePattern('if');
222
- return this.readDirectiveHeader(startLoc, TokenType.DirectiveElseIf);
223
- }
224
- this.skipWhitespace();
225
- if (this.peek() === '{') {
226
- this.advance();
227
- this.blockDepth++;
228
- return this.createToken(TokenType.DirectiveElse, '', startLoc);
229
- }
230
- throw new DriftLexerError(
231
- `Expected '{' after @else directive`,
232
- startLoc.line,
233
- startLoc.column,
234
- startLoc.offset
235
- );
236
- } else if (name === 'for') {
237
- return this.readDirectiveHeader(startLoc, TokenType.DirectiveFor);
238
- } else if (name === 'switch') {
239
- return this.readDirectiveHeader(startLoc, TokenType.DirectiveSwitch);
240
- } else if (name === 'case') {
241
- return this.readDirectiveHeader(startLoc, TokenType.DirectiveCase);
242
- } else {
243
- this.skipWhitespace();
244
- if (this.peek() === '{') {
245
- this.advance();
246
- this.blockDepth++;
247
- }
248
- return this.createToken(TokenType.DirectiveDefault, '', startLoc);
249
- }
250
- }
251
-
252
- private readDirectiveHeader(startLoc: SourceLocation, type: TokenType): Token {
253
- this.skipWhitespace();
254
- let headerContent = '';
255
- let parenDepth = 0;
256
- let inQuote: string | null = null;
257
- let isEscaped = false;
258
- let inLineComment = false;
259
- let inBlockComment = false;
260
- let inRegex = false;
261
- let inRegexCharClass = false;
262
-
263
- while (!this.isAtEnd()) {
264
- const ch = this.advance();
265
-
266
- if (inLineComment) {
267
- headerContent += ch;
268
- if (ch === '\n') inLineComment = false;
269
- continue;
270
- }
271
-
272
- if (inBlockComment) {
273
- headerContent += ch;
274
- if (ch === '/' && headerContent.endsWith('*/')) inBlockComment = false;
275
- continue;
276
- }
277
-
278
- if (inRegex) {
279
- headerContent += ch;
280
- if (isEscaped) {
281
- isEscaped = false;
282
- } else if (ch === '\\') {
283
- isEscaped = true;
284
- } else if (ch === '[') {
285
- inRegexCharClass = true;
286
- } else if (ch === ']' && inRegexCharClass) {
287
- inRegexCharClass = false;
288
- } else if (ch === '/' && !inRegexCharClass) {
289
- inRegex = false;
290
- }
291
- continue;
292
- }
293
-
294
- if (inQuote !== null) {
295
- headerContent += ch;
296
- if (isEscaped) {
297
- isEscaped = false;
298
- } else if (ch === '\\') {
299
- isEscaped = true;
300
- } else if (ch === inQuote) {
301
- inQuote = null;
302
- }
303
- continue;
304
- }
305
-
306
- if (ch === '/' && !isEscaped) {
307
- const next = this.peek();
308
- if (next === '/') {
309
- inLineComment = true;
310
- headerContent += ch;
311
- continue;
312
- } else if (next === '*') {
313
- inBlockComment = true;
314
- headerContent += ch;
315
- continue;
316
- } else if (this.isRegexStart(headerContent)) {
317
- inRegex = true;
318
- inRegexCharClass = false;
319
- headerContent += ch;
320
- continue;
321
- }
322
- }
323
-
324
- if (ch === '"' || ch === "'" || ch === '`') {
325
- inQuote = ch;
326
- headerContent += ch;
327
- continue;
328
- }
329
-
330
- if (ch === '(') {
331
- parenDepth++;
332
- headerContent += ch;
333
- continue;
334
- }
335
-
336
- if (ch === ')') {
337
- if (parenDepth > 0) parenDepth--;
338
- headerContent += ch;
339
- continue;
340
- }
341
-
342
- if (ch === '{' && parenDepth === 0) {
343
- this.blockDepth++;
344
- return this.createToken(type, headerContent.trim(), startLoc);
345
- }
346
-
347
- headerContent += ch;
348
- }
349
-
350
- throw new DriftLexerError(
351
- `Unterminated directive header, expected '{'`,
352
- startLoc.line,
353
- startLoc.column,
354
- startLoc.offset
355
- );
356
- }
357
-
358
- private readTagNameToken(isClosingTag: boolean): Token {
359
- if (this.isAtEnd()) {
360
- throw this.createUnexpectedEOFError(
361
- isClosingTag ? 'after closing tag opener' : 'after opening tag opener'
362
- );
363
- }
364
-
365
- const startLoc = this.getLocation();
366
- const ch = this.peek();
367
-
368
- if (!this.isIdentifierStart(ch)) {
369
- throw new DriftLexerError(
370
- `Expected tag name but found '${ch || 'EOF'}'`,
371
- startLoc.line,
372
- startLoc.column,
373
- startLoc.offset
374
- );
375
- }
376
-
377
- const tagName = this.readIdentifierValue();
378
- const entersRawText = !isClosingTag && isRawTextTagName(tagName);
379
-
380
- this.transitionTo({
381
- kind: LexerStateKind.BeforeAttributeName,
382
- tagName,
383
- isClosingTag,
384
- entersRawText,
385
- });
386
-
387
- return this.createToken(TokenType.Identifier, tagName, startLoc);
388
- }
389
-
390
- private readBeforeAttributeNameToken(): Token {
391
- const state = this.state;
392
- if (state.kind !== LexerStateKind.BeforeAttributeName) {
393
- throw new Error(`Expected BeforeAttributeName state but found '${state.kind}'.`);
394
- }
395
-
396
- this.skipWhitespace();
397
-
398
- if (this.isAtEnd()) {
399
- throw this.createUnexpectedEOFError(`inside tag <${state.tagName}>`);
400
- }
401
-
402
- const startLoc = this.getLocation();
403
-
404
- if (state.isClosingTag) {
405
- if (this.peek() !== '>') {
406
- throw new DriftLexerError(
407
- `Unexpected character '${this.peek()}' inside closing tag </${state.tagName}>`,
408
- startLoc.line,
409
- startLoc.column,
410
- startLoc.offset
411
- );
412
- }
413
-
414
- this.advance();
415
- this.transitionTo({ kind: LexerStateKind.Data });
416
- return this.createToken(TokenType.TagClose, '>', startLoc);
417
- }
418
-
419
- if (this.peek() === '>') {
420
- this.advance();
421
- if (state.entersRawText && isRawTextTagName(state.tagName)) {
422
- this.transitionTo({
423
- kind: LexerStateKind.RawText,
424
- tagName: state.tagName,
425
- });
426
- } else {
427
- this.transitionTo({ kind: LexerStateKind.Data });
428
- }
429
- return this.createToken(TokenType.TagClose, '>', startLoc);
430
- }
431
-
432
- if (this.peek() === '/' && this.peek(1) === '>') {
433
- this.advance();
434
- this.advance();
435
- this.transitionTo({ kind: LexerStateKind.Data });
436
- return this.createToken(TokenType.TagSelfClose, '/>', startLoc);
437
- }
438
-
439
- if (!this.isIdentifierStart(this.peek())) {
440
- throw new DriftLexerError(
441
- `Unexpected character '${this.peek()}' inside tag <${state.tagName}>`,
442
- startLoc.line,
443
- startLoc.column,
444
- startLoc.offset
445
- );
446
- }
447
-
448
- this.transitionTo({
449
- kind: LexerStateKind.AttributeName,
450
- tagName: state.tagName,
451
- attributeName: null,
452
- });
453
-
454
- return this.readAttributeNameToken();
455
- }
456
-
457
- private readAttributeNameToken(): Token {
458
- const state = this.state;
459
- if (state.kind !== LexerStateKind.AttributeName) {
460
- throw new Error(`Expected AttributeName state but found '${state.kind}'.`);
461
- }
462
-
463
- const startLoc = this.getLocation();
464
-
465
- if (!this.isIdentifierStart(this.peek())) {
466
- throw new DriftLexerError(
467
- `Expected attribute name but found '${this.peek() || 'EOF'}'`,
468
- startLoc.line,
469
- startLoc.column,
470
- startLoc.offset
471
- );
472
- }
473
-
474
- const attributeName = this.readIdentifierValue();
475
- const tagName = state.tagName;
476
-
477
- this.transitionTo({
478
- kind: LexerStateKind.AfterAttributeName,
479
- tagName,
480
- attributeName,
481
- });
482
-
483
- return this.createToken(TokenType.Identifier, attributeName, startLoc);
484
- }
485
-
486
- private readAfterAttributeNameToken(): Token {
487
- const state = this.state;
488
- if (state.kind !== LexerStateKind.AfterAttributeName) {
489
- throw new Error(`Expected AfterAttributeName state but found '${state.kind}'.`);
490
- }
491
-
492
- const tagName = state.tagName;
493
- const attributeName = state.attributeName;
494
-
495
- this.skipWhitespace();
496
-
497
- if (this.isAtEnd()) {
498
- throw this.createUnexpectedEOFError(
499
- `after attribute '${attributeName}' in <${tagName}>`
500
- );
501
- }
502
-
503
- if (this.peek() === '=') {
504
- const startLoc = this.getLocation();
505
- this.advance();
506
- this.transitionTo({
507
- kind: LexerStateKind.BeforeAttributeValue,
508
- tagName,
509
- attributeName,
510
- });
511
- return this.createToken(TokenType.Equals, '=', startLoc);
512
- }
513
-
514
- this.transitionTo({
515
- kind: LexerStateKind.BeforeAttributeName,
516
- tagName,
517
- isClosingTag: false,
518
- entersRawText: isRawTextTagName(tagName),
519
- });
520
-
521
- return this.readBeforeAttributeNameToken();
522
- }
523
-
524
- private readBeforeAttributeValueToken(): Token {
525
- const state = this.state;
526
- if (state.kind !== LexerStateKind.BeforeAttributeValue) {
527
- throw new Error(`Expected BeforeAttributeValue state but found '${state.kind}'.`);
528
- }
529
-
530
- const tagName = state.tagName;
531
- const attributeName = state.attributeName;
532
-
533
- this.skipWhitespace();
534
-
535
- if (this.isAtEnd()) {
536
- throw this.createUnexpectedEOFError(
537
- `before value for attribute '${attributeName}' in <${tagName}>`
538
- );
539
- }
540
-
541
- const startLoc = this.getLocation();
542
- const ch = this.peek();
543
-
544
- if (ch === '"' || ch === "'") {
545
- this.advance();
546
- this.transitionTo({
547
- kind: LexerStateKind.AttributeValueQuoted,
548
- tagName,
549
- attributeName,
550
- quote: ch,
551
- });
552
- return this.readQuotedStringToken(startLoc, ch, tagName);
553
- }
554
-
555
- if (ch === '{') {
556
- this.advance();
557
- this.transitionTo({
558
- kind: LexerStateKind.AttributeValueInterpolation,
559
- tagName,
560
- attributeName,
561
- });
562
- return this.readInterpolationToken(startLoc, 'attribute', tagName);
563
- }
564
-
565
- throw new DriftLexerError(
566
- `Expected quoted string or interpolation for attribute '${attributeName}' in <${tagName}>`,
567
- startLoc.line,
568
- startLoc.column,
569
- startLoc.offset
570
- );
571
- }
572
-
573
- private readCommentToken(startLoc: SourceLocation): Token {
574
- let commentContent = '';
575
-
576
- while (!this.isAtEnd()) {
577
- if (this.startsWith('-->')) {
578
- this.consumePattern('-->');
579
- this.transitionTo({ kind: LexerStateKind.Data });
580
- return this.createToken(TokenType.Comment, commentContent, startLoc);
581
- }
582
-
583
- commentContent += this.advance();
584
- }
585
-
586
- throw new DriftLexerError(
587
- 'Unterminated XML comment',
588
- startLoc.line,
589
- startLoc.column,
590
- startLoc.offset
591
- );
592
- }
593
-
594
- private isRegexStart(expr: string): boolean {
595
- const trimmed = expr.trimEnd();
596
- if (trimmed.length === 0) return true;
597
- const lastChar = trimmed[trimmed.length - 1];
598
- if ('=(,:;!&|?[{}+-*%<>~^'.includes(lastChar!)) return true;
599
- const lastWord = trimmed.split(/\s+/).pop();
600
- if (lastWord && ['return', 'yield', 'await', 'case', 'typeof', 'void', 'delete', 'instanceof', 'in', 'do'].includes(lastWord)) return true;
601
- return false;
602
- }
603
-
604
- private readInterpolationToken(
605
- startLoc: SourceLocation,
606
- context: 'content' | 'attribute',
607
- tagName: string | null
608
- ): Token {
609
- let braceDepth = 1;
610
- let inStringQuote: string | null = null;
611
- let isEscaped = false;
612
- let inLineComment = false;
613
- let inBlockComment = false;
614
- let inRegex = false;
615
- let inRegexCharClass = false;
616
- let templateStack: number[] = [];
617
- let expression = '';
618
-
619
- while (!this.isAtEnd()) {
620
- const ch = this.advance();
621
-
622
- if (inLineComment) {
623
- expression += ch;
624
- if (ch === '\n') {
625
- inLineComment = false;
626
- }
627
- continue;
628
- }
629
-
630
- if (inBlockComment) {
631
- expression += ch;
632
- if (ch === '/' && expression.endsWith('*/')) {
633
- inBlockComment = false;
634
- }
635
- continue;
636
- }
637
-
638
- if (inRegex) {
639
- expression += ch;
640
- if (isEscaped) {
641
- isEscaped = false;
642
- } else if (ch === '\\') {
643
- isEscaped = true;
644
- } else if (ch === '[') {
645
- inRegexCharClass = true;
646
- } else if (ch === ']' && inRegexCharClass) {
647
- inRegexCharClass = false;
648
- } else if (ch === '/' && !inRegexCharClass) {
649
- inRegex = false;
650
- }
651
- continue;
652
- }
653
-
654
- if (inStringQuote !== null) {
655
- expression += ch;
656
- if (isEscaped) {
657
- isEscaped = false;
658
- } else if (ch === '\\') {
659
- isEscaped = true;
660
- } else if (ch === inStringQuote) {
661
- inStringQuote = null;
662
- } else if (inStringQuote === '`' && ch === '{' && expression.endsWith('${')) {
663
- templateStack.push(braceDepth);
664
- inStringQuote = null;
665
- braceDepth++;
666
- }
667
- continue;
668
- }
669
-
670
- if (ch === '/' && !isEscaped) {
671
- const next = this.peek();
672
- if (next === '/') {
673
- inLineComment = true;
674
- expression += ch;
675
- continue;
676
- } else if (next === '*') {
677
- inBlockComment = true;
678
- expression += ch;
679
- continue;
680
- } else if (this.isRegexStart(expression)) {
681
- inRegex = true;
682
- inRegexCharClass = false;
683
- expression += ch;
684
- continue;
685
- }
686
- }
687
-
688
- if (ch === '"' || ch === "'" || ch === '`') {
689
- inStringQuote = ch;
690
- expression += ch;
691
- continue;
692
- }
693
-
694
- if (ch === '{') {
695
- braceDepth++;
696
- expression += ch;
697
- continue;
698
- }
699
-
700
- if (ch === '}') {
701
- braceDepth--;
702
- if (templateStack.length > 0 && braceDepth === templateStack[templateStack.length - 1]) {
703
- templateStack.pop();
704
- inStringQuote = '`';
705
- }
706
-
707
- if (braceDepth === 0) {
708
- if (context === 'attribute' && tagName !== null) {
709
- this.transitionTo({
710
- kind: LexerStateKind.BeforeAttributeName,
711
- tagName,
712
- isClosingTag: false,
713
- entersRawText: isRawTextTagName(tagName),
714
- });
715
- } else {
716
- this.transitionTo({ kind: LexerStateKind.Data });
717
- }
718
-
719
- return this.createToken(TokenType.Interpolation, expression, startLoc);
720
- }
721
-
722
- expression += ch;
723
- continue;
724
- }
725
-
726
- expression += ch;
727
- }
728
-
729
- throw new DriftLexerError(
730
- 'Unterminated interpolation expression, expected closing brace \'}\'',
731
- startLoc.line,
732
- startLoc.column,
733
- startLoc.offset
734
- );
735
- }
736
-
737
- private readRawTextToken(): Token {
738
- const state = this.state;
739
- if (state.kind !== LexerStateKind.RawText) {
740
- throw new Error(`Expected RawText state but found '${state.kind}'.`);
741
- }
742
-
743
- const startLoc = this.getLocation();
744
- const closingSequence = `</${state.tagName}`;
745
-
746
- if (this.isAtEnd()) {
747
- this.transitionTo({ kind: LexerStateKind.EOF });
748
- return this.getOrCreateEOFToken(startLoc);
749
- }
750
-
751
- if (this.isRawTextClosingTagAhead(closingSequence)) {
752
- this.consumePattern('</');
753
- this.transitionTo({ kind: LexerStateKind.EndTagOpen });
754
- return this.createToken(TokenType.TagOpenSlash, '</', startLoc);
755
- }
756
-
757
- let text = '';
758
-
759
- while (!this.isAtEnd()) {
760
- if (this.isRawTextClosingTagAhead(closingSequence)) {
761
- break;
762
- }
763
- text += this.advance();
764
- }
765
-
766
- if (text.length > 0) {
767
- return this.createToken(TokenType.Text, text, startLoc);
768
- }
769
-
770
- this.transitionTo({ kind: LexerStateKind.EOF });
771
- return this.getOrCreateEOFToken(startLoc);
772
- }
773
-
774
- private readTextToken(): Token {
775
- const startLoc = this.getLocation();
776
- let text = '';
777
-
778
- while (!this.isAtEnd()) {
779
- const ch = this.peek();
780
- if (ch === '<' || ch === '{' || ch === '@' || (ch === '}' && this.blockDepth > 0)) {
781
- break;
782
- }
783
- text += this.advance();
784
- }
785
-
786
- return this.createToken(TokenType.Text, text, startLoc);
787
- }
788
-
789
- private readQuotedStringToken(
790
- startLoc: SourceLocation,
791
- quote: '"' | "'",
792
- tagName: string
793
- ): Token {
794
- let value = '';
795
-
796
- while (!this.isAtEnd()) {
797
- const ch = this.peek();
798
- if (ch === quote) {
799
- this.advance();
800
- this.transitionTo({
801
- kind: LexerStateKind.BeforeAttributeName,
802
- tagName,
803
- isClosingTag: false,
804
- entersRawText: isRawTextTagName(tagName),
805
- });
806
- return this.createToken(TokenType.StringLiteral, value, startLoc);
807
- }
808
- value += this.advance();
809
- }
810
-
811
- throw new DriftLexerError(
812
- `Unterminated string literal, expected closing quote ${quote}`,
813
- startLoc.line,
814
- startLoc.column,
815
- startLoc.offset
816
- );
817
- }
818
-
819
- private readIdentifierValue(): string {
820
- let value = '';
821
-
822
- while (!this.isAtEnd() && this.isIdentifierChar(this.peek())) {
823
- value += this.advance();
824
- }
825
-
826
- return value;
827
- }
828
-
829
- private getOrCreateEOFToken(startLoc: SourceLocation = this.getLocation()): Token {
830
- if (this.eofToken !== null) {
831
- return this.eofToken;
832
- }
833
-
834
- this.eofToken = {
835
- type: TokenType.EOF,
836
- value: '',
837
- loc: { start: startLoc, end: startLoc },
838
- };
839
-
840
- return this.eofToken;
841
- }
842
-
843
- private createToken(type: TokenType, value: string, start: SourceLocation): Token {
844
- return {
845
- type,
846
- value,
847
- loc: { start, end: this.getLocation() },
848
- };
849
- }
850
-
851
- private createUnexpectedEOFError(context: string): DriftLexerError {
852
- const loc = this.getLocation();
853
- return new DriftLexerError(
854
- `Unexpected end of input ${context}`,
855
- loc.line,
856
- loc.column,
857
- loc.offset
858
- );
859
- }
860
-
861
- private transitionTo(nextState: DriftLexerState): void {
862
- const allowedTransitions = LEXER_STATE_TRANSITIONS[this.state.kind];
863
- const isAllowed = allowedTransitions.some((transition) => transition.to === nextState.kind);
864
-
865
- if (!isAllowed) {
866
- throw new Error(`Invalid lexer transition from '${this.state.kind}' to '${nextState.kind}'.`);
867
- }
868
-
869
- this.state = nextState;
870
- }
871
-
872
- private getLocation(): SourceLocation {
873
- return {
874
- line: this.line,
875
- column: this.column,
876
- offset: this.offset,
877
- };
878
- }
879
-
880
- private isAtEnd(): boolean {
881
- return this.offset >= this.source.length;
882
- }
883
-
884
- private peek(relativeOffset = 0): string {
885
- const target = this.offset + relativeOffset;
886
- if (target >= this.source.length) {
887
- return '';
888
- }
889
- return this.source[target] ?? '';
890
- }
891
-
892
- private advance(): string {
893
- const ch = this.source[this.offset] ?? '';
894
- this.offset++;
895
- if (ch === '\n') {
896
- this.line++;
897
- this.column = 1;
898
- } else {
899
- this.column++;
900
- }
901
- return ch;
902
- }
903
-
904
- private startsWith(pattern: string): boolean {
905
- return this.source.startsWith(pattern, this.offset);
906
- }
907
-
908
- private consumePattern(pattern: string): void {
909
- for (let i = 0; i < pattern.length; i++) {
910
- this.advance();
911
- }
912
- }
913
-
914
- private skipWhitespace(): void {
915
- while (!this.isAtEnd()) {
916
- const ch = this.peek();
917
- if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
918
- this.advance();
919
- } else {
920
- break;
921
- }
922
- }
923
- }
924
-
925
- private isIdentifierStart(ch: string): boolean {
926
- if (ch.length === 0) return false;
927
- const code = ch.charCodeAt(0);
928
- return (
929
- (code >= 65 && code <= 90) ||
930
- (code >= 97 && code <= 122)
931
- );
932
- }
933
-
934
- private isIdentifierChar(ch: string): boolean {
935
- if (ch.length === 0) return false;
936
- const code = ch.charCodeAt(0);
937
- return (
938
- (code >= 65 && code <= 90) ||
939
- (code >= 97 && code <= 122) ||
940
- (code >= 48 && code <= 57) ||
941
- code === 95 ||
942
- code === 45
943
- );
944
- }
945
-
946
- private isRawTextClosingTagAhead(closingSequence: string): boolean {
947
- if (!this.startsWith(closingSequence)) {
948
- return false;
949
- }
950
-
951
- const boundary = this.peek(closingSequence.length);
952
- return boundary === '' || boundary === '>' || boundary === ' ' || boundary === '\t' || boundary === '\r' || boundary === '\n';
953
- }
954
- }