@chilts/weave 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,679 @@
1
+ export class LexerError extends Error {
2
+ line;
3
+ col;
4
+ constructor(message, line, col) {
5
+ super(`${message} at line ${line}, col ${col}`);
6
+ this.name = 'LexerError';
7
+ this.line = line;
8
+ this.col = col;
9
+ }
10
+ }
11
+ // Keywords that are recognized at the start of a line.
12
+ // Each must be followed by a space, '(', or end-of-line to be treated as a keyword.
13
+ const KEYWORDS = new Set([
14
+ 'import',
15
+ 'template',
16
+ 'mixin',
17
+ 'extends',
18
+ 'block',
19
+ 'include',
20
+ 'doctype',
21
+ 'if',
22
+ 'for',
23
+ ]);
24
+ class Lexer {
25
+ lines;
26
+ lineIndex = 0;
27
+ pos = 0;
28
+ currentLine = '';
29
+ indentWidth = 0; // leading spaces of current line
30
+ indentStack = [0];
31
+ tokens = [];
32
+ constructor(source) {
33
+ this.lines = source.split('\n');
34
+ }
35
+ lex() {
36
+ for (this.lineIndex = 0; this.lineIndex < this.lines.length; this.lineIndex++) {
37
+ const rawLine = this.lines[this.lineIndex];
38
+ // Skip blank lines (empty or whitespace-only)
39
+ if (rawLine.trim().length === 0) {
40
+ continue;
41
+ }
42
+ // Measure indentation
43
+ this.indentWidth = this.measureIndent(rawLine);
44
+ this.currentLine = rawLine.slice(this.indentWidth);
45
+ this.pos = 0;
46
+ // Emit INDENT/DEDENT tokens
47
+ this.emitIndentation(this.indentWidth);
48
+ // Dispatch based on line content
49
+ this.processLine();
50
+ // Emit NEWLINE
51
+ this.emit('NEWLINE', '');
52
+ }
53
+ // Close any remaining indentation
54
+ while (this.indentStack.length > 1) {
55
+ this.indentStack.pop();
56
+ this.emit('DEDENT', '');
57
+ }
58
+ this.emit('EOF', '');
59
+ return this.tokens;
60
+ }
61
+ measureIndent(line) {
62
+ let indent = 0;
63
+ for (let i = 0; i < line.length; i++) {
64
+ if (line[i] === ' ') {
65
+ indent++;
66
+ }
67
+ else if (line[i] === '\t') {
68
+ throw new LexerError('Tab characters are not allowed for indentation, use spaces', this.lineIndex + 1, i + 1);
69
+ }
70
+ else {
71
+ break;
72
+ }
73
+ }
74
+ return indent;
75
+ }
76
+ emitIndentation(indent) {
77
+ const current = this.indentStack[this.indentStack.length - 1];
78
+ if (indent > current) {
79
+ this.indentStack.push(indent);
80
+ this.emit('INDENT', '');
81
+ }
82
+ else if (indent < current) {
83
+ while (this.indentStack[this.indentStack.length - 1] > indent) {
84
+ this.indentStack.pop();
85
+ this.emit('DEDENT', '');
86
+ }
87
+ if (this.indentStack[this.indentStack.length - 1] !== indent) {
88
+ throw new LexerError('Inconsistent indentation', this.lineIndex + 1, indent + 1);
89
+ }
90
+ }
91
+ }
92
+ processLine() {
93
+ const line = this.currentLine;
94
+ // Comment: //-
95
+ if (line.startsWith('//-')) {
96
+ this.scanComment();
97
+ return;
98
+ }
99
+ // Pass-through comment: //
100
+ if (line.startsWith('//')) {
101
+ this.scanPassthroughComment();
102
+ return;
103
+ }
104
+ // else if (must check before 'else' alone)
105
+ if (line === 'else if' || line.startsWith('else if ') || line.startsWith('else if(')) {
106
+ this.scanElseIf();
107
+ return;
108
+ }
109
+ // else
110
+ if (line === 'else') {
111
+ this.emit('ELSE', 'else');
112
+ this.pos = 4;
113
+ return;
114
+ }
115
+ // Check single-word keywords with word boundary
116
+ for (const keyword of KEYWORDS) {
117
+ if (this.matchesKeyword(line, keyword)) {
118
+ this.dispatchKeyword(keyword);
119
+ return;
120
+ }
121
+ }
122
+ // Mixin call: +name(...)
123
+ if (line.startsWith('+')) {
124
+ this.scanMixinCall();
125
+ return;
126
+ }
127
+ // Pipe text: | content
128
+ if (line.startsWith('|')) {
129
+ this.scanPipe();
130
+ return;
131
+ }
132
+ // Element: starts with tag, .class, or #id
133
+ this.scanElement();
134
+ }
135
+ matchesKeyword(line, keyword) {
136
+ if (!line.startsWith(keyword))
137
+ return false;
138
+ if (line.length === keyword.length)
139
+ return true;
140
+ const next = line[keyword.length];
141
+ return next === ' ' || next === '(';
142
+ }
143
+ dispatchKeyword(keyword) {
144
+ switch (keyword) {
145
+ case 'import':
146
+ this.scanImport();
147
+ break;
148
+ case 'template':
149
+ this.scanTemplate();
150
+ break;
151
+ case 'mixin':
152
+ this.scanMixin();
153
+ break;
154
+ case 'extends':
155
+ this.scanExtends();
156
+ break;
157
+ case 'block':
158
+ this.scanBlock();
159
+ break;
160
+ case 'include':
161
+ this.scanInclude();
162
+ break;
163
+ case 'doctype':
164
+ this.scanDoctype();
165
+ break;
166
+ case 'if':
167
+ this.scanIf();
168
+ break;
169
+ case 'for':
170
+ this.scanFor();
171
+ break;
172
+ }
173
+ }
174
+ // --- Simple scanners ---
175
+ scanComment() {
176
+ // //- or //- text
177
+ const text = this.currentLine.length > 3 ? this.currentLine.slice(3).trimStart() : '';
178
+ this.emit('COMMENT', text);
179
+ this.pos = this.currentLine.length;
180
+ }
181
+ scanPassthroughComment() {
182
+ // // or // text — capture everything after //
183
+ const text = this.currentLine.slice(2);
184
+ this.emit('PASSTHROUGH_COMMENT', text);
185
+ this.pos = this.currentLine.length;
186
+ }
187
+ scanImport() {
188
+ this.emit('IMPORT', this.currentLine);
189
+ this.pos = this.currentLine.length;
190
+ }
191
+ scanDoctype() {
192
+ const variant = this.currentLine.slice('doctype '.length);
193
+ this.emit('DOCTYPE', variant);
194
+ this.pos = this.currentLine.length;
195
+ }
196
+ scanBlock() {
197
+ this.emit('BLOCK', 'block');
198
+ const name = this.currentLine.slice('block '.length).trim();
199
+ this.emit('IDENTIFIER', name);
200
+ this.pos = this.currentLine.length;
201
+ }
202
+ scanInclude() {
203
+ this.emit('INCLUDE', 'include');
204
+ const path = this.currentLine.slice('include '.length).trim();
205
+ this.emit('PATH', path);
206
+ this.pos = this.currentLine.length;
207
+ }
208
+ // --- Declaration scanners ---
209
+ scanTemplate() {
210
+ this.pos = 'template '.length;
211
+ this.emit('TEMPLATE', 'template');
212
+ const name = this.scanIdentifier();
213
+ this.emit('IDENTIFIER', name);
214
+ if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
215
+ const params = this.scanBalancedParens();
216
+ this.emit('PARAMS', params);
217
+ }
218
+ }
219
+ scanMixin() {
220
+ this.pos = 'mixin '.length;
221
+ this.emit('MIXIN', 'mixin');
222
+ const name = this.scanIdentifier();
223
+ this.emit('IDENTIFIER', name);
224
+ if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
225
+ const params = this.scanBalancedParens();
226
+ this.emit('PARAMS', params);
227
+ }
228
+ }
229
+ scanExtends() {
230
+ this.pos = 'extends '.length;
231
+ this.emit('EXTENDS', 'extends');
232
+ const name = this.scanIdentifier();
233
+ this.emit('IDENTIFIER', name);
234
+ if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
235
+ const params = this.scanBalancedParens();
236
+ this.emit('PARAMS', params);
237
+ }
238
+ }
239
+ // --- Control flow scanners ---
240
+ scanIf() {
241
+ this.pos = 'if '.length;
242
+ this.emit('IF', 'if');
243
+ const expr = this.currentLine.slice(this.pos);
244
+ this.emit('BUFFERED_CODE', expr);
245
+ this.pos = this.currentLine.length;
246
+ }
247
+ scanElseIf() {
248
+ this.pos = 'else if '.length;
249
+ this.emit('ELSE_IF', 'else if');
250
+ const expr = this.currentLine.slice(this.pos);
251
+ this.emit('BUFFERED_CODE', expr);
252
+ this.pos = this.currentLine.length;
253
+ }
254
+ scanFor() {
255
+ this.pos = 'for '.length;
256
+ this.emit('FOR', 'for');
257
+ this.skipSpaces();
258
+ if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
259
+ const params = this.scanBalancedParens();
260
+ this.emit('PARAMS', params);
261
+ }
262
+ }
263
+ // --- Element scanner ---
264
+ scanElement() {
265
+ const ch = this.currentLine[this.pos];
266
+ // Tag name or implicit div
267
+ if (ch === '.' || ch === '#') {
268
+ this.emit('TAG', 'div');
269
+ }
270
+ else {
271
+ const tag = this.scanTagName();
272
+ this.emit('TAG', tag);
273
+ }
274
+ // Classes and IDs
275
+ this.scanClassesAndIds();
276
+ // Attributes
277
+ if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
278
+ this.emit('ATTR_START', '(');
279
+ this.pos++; // skip '('
280
+ this.scanAttributes();
281
+ this.emit('ATTR_END', ')');
282
+ }
283
+ // Trailing content
284
+ this.scanTrailingContent();
285
+ }
286
+ scanTagName() {
287
+ const start = this.pos;
288
+ while (this.pos < this.currentLine.length) {
289
+ const ch = this.currentLine[this.pos];
290
+ if (/[a-zA-Z0-9-]/.test(ch)) {
291
+ this.pos++;
292
+ }
293
+ else {
294
+ break;
295
+ }
296
+ }
297
+ return this.currentLine.slice(start, this.pos);
298
+ }
299
+ scanClassesAndIds() {
300
+ while (this.pos < this.currentLine.length) {
301
+ const ch = this.currentLine[this.pos];
302
+ if (ch === '.') {
303
+ this.pos++; // skip dot
304
+ const start = this.pos;
305
+ while (this.pos < this.currentLine.length) {
306
+ const c = this.currentLine[this.pos];
307
+ if (c === ' ' || c === '(' || c === '#' || c === '.' || c === '=')
308
+ break;
309
+ this.pos++;
310
+ }
311
+ this.emit('CLASS', this.currentLine.slice(start, this.pos));
312
+ }
313
+ else if (ch === '#') {
314
+ this.pos++; // skip hash
315
+ const start = this.pos;
316
+ while (this.pos < this.currentLine.length) {
317
+ const c = this.currentLine[this.pos];
318
+ if (c === ' ' || c === '(' || c === '#' || c === '.' || c === '=')
319
+ break;
320
+ this.pos++;
321
+ }
322
+ this.emit('ID', this.currentLine.slice(start, this.pos));
323
+ }
324
+ else {
325
+ break;
326
+ }
327
+ }
328
+ }
329
+ scanAttributes() {
330
+ while (this.pos < this.currentLine.length) {
331
+ this.skipSpaces();
332
+ if (this.pos >= this.currentLine.length)
333
+ break;
334
+ if (this.currentLine[this.pos] === ')') {
335
+ this.pos++; // skip closing paren
336
+ return;
337
+ }
338
+ // Spread: ...identifier
339
+ if (this.currentLine[this.pos] === '.' &&
340
+ this.pos + 2 < this.currentLine.length &&
341
+ this.currentLine[this.pos + 1] === '.' &&
342
+ this.currentLine[this.pos + 2] === '.') {
343
+ this.pos += 3; // skip ...
344
+ const name = this.scanIdentifier();
345
+ this.emit('SPREAD', name);
346
+ continue;
347
+ }
348
+ // Attribute name
349
+ const name = this.scanAttrName();
350
+ this.emit('ATTR_NAME', name);
351
+ // Check for = value
352
+ if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '=') {
353
+ this.pos++; // skip =
354
+ const value = this.scanAttrValue();
355
+ this.emit('ATTR_VALUE', value);
356
+ }
357
+ }
358
+ }
359
+ scanAttrName() {
360
+ const start = this.pos;
361
+ while (this.pos < this.currentLine.length) {
362
+ const ch = this.currentLine[this.pos];
363
+ if (/[a-zA-Z0-9_-]/.test(ch)) {
364
+ this.pos++;
365
+ }
366
+ else {
367
+ break;
368
+ }
369
+ }
370
+ return this.currentLine.slice(start, this.pos);
371
+ }
372
+ scanAttrValue() {
373
+ const ch = this.currentLine[this.pos];
374
+ // Quoted string
375
+ if (ch === '"' || ch === "'") {
376
+ return this.scanString(ch);
377
+ }
378
+ // Template literal
379
+ if (ch === '`') {
380
+ return this.scanTemplateLiteral();
381
+ }
382
+ // Bare expression — scan until whitespace, ), or end
383
+ const start = this.pos;
384
+ let depth = 0;
385
+ while (this.pos < this.currentLine.length) {
386
+ const c = this.currentLine[this.pos];
387
+ if (c === '(') {
388
+ depth++;
389
+ this.pos++;
390
+ }
391
+ else if (c === ')') {
392
+ if (depth === 0)
393
+ break;
394
+ depth--;
395
+ this.pos++;
396
+ }
397
+ else if (c === ' ' && depth === 0) {
398
+ break;
399
+ }
400
+ else {
401
+ this.pos++;
402
+ }
403
+ }
404
+ return this.currentLine.slice(start, this.pos);
405
+ }
406
+ scanTrailingContent() {
407
+ if (this.pos >= this.currentLine.length)
408
+ return;
409
+ const ch = this.currentLine[this.pos];
410
+ // Buffered code: = expression
411
+ if (ch === '=') {
412
+ this.pos++; // skip =
413
+ if (this.pos < this.currentLine.length && this.currentLine[this.pos] === ' ') {
414
+ this.pos++; // skip space after =
415
+ }
416
+ const rest = this.currentLine.slice(this.pos);
417
+ if (rest.includes('#{')) {
418
+ this.scanTextWithInterpolation(rest);
419
+ }
420
+ else {
421
+ this.emit('BUFFERED_CODE', rest);
422
+ }
423
+ this.pos = this.currentLine.length;
424
+ return;
425
+ }
426
+ // Inline text: space then text
427
+ if (ch === ' ') {
428
+ this.pos++; // skip leading space
429
+ const rest = this.currentLine.slice(this.pos);
430
+ this.scanTextWithInterpolation(rest);
431
+ this.pos = this.currentLine.length;
432
+ return;
433
+ }
434
+ }
435
+ // --- Mixin call scanner ---
436
+ scanMixinCall() {
437
+ this.pos = 1; // skip +
438
+ const name = this.scanIdentifier();
439
+ this.emit('MIXIN_CALL', name);
440
+ // Args in first set of parens
441
+ if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
442
+ const args = this.scanBalancedParens();
443
+ this.emit('PARAMS', args);
444
+ }
445
+ // Extra attrs in second set of parens
446
+ if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
447
+ this.emit('ATTR_START', '(');
448
+ this.pos++; // skip '('
449
+ this.scanAttributes();
450
+ this.emit('ATTR_END', ')');
451
+ }
452
+ }
453
+ // --- Pipe scanner ---
454
+ scanPipe() {
455
+ this.emit('PIPE', '|');
456
+ this.pos = 1; // skip |
457
+ if (this.pos < this.currentLine.length && this.currentLine[this.pos] === ' ') {
458
+ this.pos++; // skip space after |
459
+ }
460
+ const rest = this.currentLine.slice(this.pos);
461
+ if (rest.length > 0) {
462
+ this.scanTextWithInterpolation(rest);
463
+ }
464
+ this.pos = this.currentLine.length;
465
+ }
466
+ // --- Text with interpolation ---
467
+ scanTextWithInterpolation(text) {
468
+ let i = 0;
469
+ let buf = '';
470
+ while (i < text.length) {
471
+ // Check for interpolation #{...}
472
+ if (text[i] === '#' && i + 1 < text.length && text[i + 1] === '{') {
473
+ // Emit accumulated text
474
+ if (buf.length > 0) {
475
+ this.emit('TEXT', buf);
476
+ buf = '';
477
+ }
478
+ i += 2; // skip #{
479
+ // Scan balanced braces to find closing }
480
+ let depth = 1;
481
+ const start = i;
482
+ while (i < text.length && depth > 0) {
483
+ const ch = text[i];
484
+ if (ch === '{') {
485
+ depth++;
486
+ }
487
+ else if (ch === '}') {
488
+ depth--;
489
+ if (depth === 0)
490
+ break;
491
+ }
492
+ else if (ch === '"' || ch === "'" || ch === '`') {
493
+ // Skip string contents
494
+ const quote = ch;
495
+ i++;
496
+ while (i < text.length) {
497
+ if (text[i] === '\\') {
498
+ i++; // skip escaped char
499
+ }
500
+ else if (text[i] === quote) {
501
+ break;
502
+ }
503
+ i++;
504
+ }
505
+ }
506
+ i++;
507
+ }
508
+ if (depth !== 0) {
509
+ throw new LexerError('Unterminated interpolation', this.lineIndex + 1, this.indentWidth + this.pos + 1);
510
+ }
511
+ this.emit('INTERPOLATION', text.slice(start, i));
512
+ i++; // skip closing }
513
+ }
514
+ else {
515
+ buf += text[i];
516
+ i++;
517
+ }
518
+ }
519
+ // Emit remaining text
520
+ if (buf.length > 0) {
521
+ this.emit('TEXT', buf);
522
+ }
523
+ }
524
+ // --- Balanced scanning helpers ---
525
+ scanBalancedParens() {
526
+ if (this.currentLine[this.pos] !== '(') {
527
+ throw new LexerError('Expected opening parenthesis', this.lineIndex + 1, this.indentWidth + this.pos + 1);
528
+ }
529
+ this.pos++; // skip opening (
530
+ let depth = 1;
531
+ const start = this.pos;
532
+ while (this.pos < this.currentLine.length && depth > 0) {
533
+ const ch = this.currentLine[this.pos];
534
+ if (ch === '(') {
535
+ depth++;
536
+ this.pos++;
537
+ }
538
+ else if (ch === ')') {
539
+ depth--;
540
+ if (depth === 0)
541
+ break;
542
+ this.pos++;
543
+ }
544
+ else if (ch === '"' || ch === "'" || ch === '`') {
545
+ this.skipStringInPlace(ch);
546
+ }
547
+ else {
548
+ this.pos++;
549
+ }
550
+ }
551
+ if (depth !== 0) {
552
+ throw new LexerError('Unterminated parentheses', this.lineIndex + 1, this.indentWidth + this.pos + 1);
553
+ }
554
+ const content = this.currentLine.slice(start, this.pos);
555
+ this.pos++; // skip closing )
556
+ return content.trim();
557
+ }
558
+ scanString(quote) {
559
+ const start = this.pos;
560
+ this.pos++; // skip opening quote
561
+ while (this.pos < this.currentLine.length) {
562
+ if (this.currentLine[this.pos] === '\\') {
563
+ this.pos += 2; // skip escaped char
564
+ }
565
+ else if (this.currentLine[this.pos] === quote) {
566
+ this.pos++; // skip closing quote
567
+ return this.currentLine.slice(start, this.pos);
568
+ }
569
+ else {
570
+ this.pos++;
571
+ }
572
+ }
573
+ throw new LexerError('Unterminated string', this.lineIndex + 1, this.indentWidth + this.pos + 1);
574
+ }
575
+ scanTemplateLiteral() {
576
+ const start = this.pos;
577
+ this.pos++; // skip opening backtick
578
+ while (this.pos < this.currentLine.length) {
579
+ if (this.currentLine[this.pos] === '\\') {
580
+ this.pos += 2;
581
+ }
582
+ else if (this.currentLine[this.pos] === '`') {
583
+ this.pos++;
584
+ return this.currentLine.slice(start, this.pos);
585
+ }
586
+ else if (this.currentLine[this.pos] === '$' &&
587
+ this.pos + 1 < this.currentLine.length &&
588
+ this.currentLine[this.pos + 1] === '{') {
589
+ this.pos += 2; // skip ${
590
+ let depth = 1;
591
+ while (this.pos < this.currentLine.length && depth > 0) {
592
+ if (this.currentLine[this.pos] === '{') {
593
+ depth++;
594
+ }
595
+ else if (this.currentLine[this.pos] === '}') {
596
+ depth--;
597
+ }
598
+ else if (this.currentLine[this.pos] === '"' ||
599
+ this.currentLine[this.pos] === "'" ||
600
+ this.currentLine[this.pos] === '`') {
601
+ this.skipStringInPlace(this.currentLine[this.pos]);
602
+ continue;
603
+ }
604
+ this.pos++;
605
+ }
606
+ }
607
+ else {
608
+ this.pos++;
609
+ }
610
+ }
611
+ throw new LexerError('Unterminated template literal', this.lineIndex + 1, this.indentWidth + this.pos + 1);
612
+ }
613
+ /** Skip over a string in-place (used within balanced paren scanning) */
614
+ skipStringInPlace(quote) {
615
+ this.pos++; // skip opening quote
616
+ while (this.pos < this.currentLine.length) {
617
+ if (this.currentLine[this.pos] === '\\') {
618
+ this.pos += 2;
619
+ }
620
+ else if (this.currentLine[this.pos] === quote) {
621
+ this.pos++;
622
+ return;
623
+ }
624
+ else if (quote === '`' &&
625
+ this.currentLine[this.pos] === '$' &&
626
+ this.pos + 1 < this.currentLine.length &&
627
+ this.currentLine[this.pos + 1] === '{') {
628
+ // Template literal ${...} - skip the nested expression
629
+ this.pos += 2;
630
+ let depth = 1;
631
+ while (this.pos < this.currentLine.length && depth > 0) {
632
+ if (this.currentLine[this.pos] === '{')
633
+ depth++;
634
+ else if (this.currentLine[this.pos] === '}')
635
+ depth--;
636
+ if (depth > 0)
637
+ this.pos++;
638
+ }
639
+ if (this.pos < this.currentLine.length)
640
+ this.pos++; // skip closing }
641
+ return;
642
+ }
643
+ else {
644
+ this.pos++;
645
+ }
646
+ }
647
+ }
648
+ // --- Utility methods ---
649
+ scanIdentifier() {
650
+ const start = this.pos;
651
+ while (this.pos < this.currentLine.length) {
652
+ const ch = this.currentLine[this.pos];
653
+ if (/[a-zA-Z0-9_]/.test(ch)) {
654
+ this.pos++;
655
+ }
656
+ else {
657
+ break;
658
+ }
659
+ }
660
+ return this.currentLine.slice(start, this.pos);
661
+ }
662
+ skipSpaces() {
663
+ while (this.pos < this.currentLine.length && this.currentLine[this.pos] === ' ') {
664
+ this.pos++;
665
+ }
666
+ }
667
+ emit(type, value) {
668
+ this.tokens.push({
669
+ type,
670
+ value,
671
+ line: this.lineIndex + 1,
672
+ col: this.indentWidth + this.pos + 1,
673
+ });
674
+ }
675
+ }
676
+ export function lex(source) {
677
+ return new Lexer(source).lex();
678
+ }
679
+ //# sourceMappingURL=index.js.map