@chilts/weave 0.6.0 → 0.8.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.
@@ -19,8 +19,24 @@ const KEYWORDS = new Set([
19
19
  'include',
20
20
  'doctype',
21
21
  'if',
22
+ 'unless',
22
23
  'for',
23
24
  ]);
25
+ // Pug's 'block append' / 'block prepend' have no Weave form: a page fills
26
+ // a block by replacing it, so extra content needs a slot of its own.
27
+ const APPEND_HINT = "there is no 'append' or 'prepend'; add a second, empty slot to the layout " +
28
+ "(e.g. 'block pageScripts' after 'block scripts') and fill that instead";
29
+ // Pug keywords with no Weave equivalent, and what to write instead. Without
30
+ // this they lex as elements and render as e.g. <case> and <when>.
31
+ const PUG_KEYWORDS = new Map([
32
+ ['case', "use 'if' / 'else if' / 'else' instead of 'case' / 'when'"],
33
+ ['when', "use 'if' / 'else if' / 'else' instead of 'case' / 'when'"],
34
+ ['default', "use 'else' instead of 'case' / 'default'"],
35
+ ['each', "use 'for ( const item of items )' instead of 'each'"],
36
+ ['while', "iterate with 'for ( const item of items )' instead of 'while'"],
37
+ ['append', APPEND_HINT],
38
+ ['prepend', APPEND_HINT],
39
+ ]);
24
40
  class Lexer {
25
41
  lines;
26
42
  lineIndex = 0;
@@ -54,7 +70,9 @@ class Lexer {
54
70
  }
55
71
  // Preserve indentation relative to the base
56
72
  const text = rawLine.slice(this.rawBlockBaseIndent);
57
- this.emit('RAW_TEXT', text);
73
+ // The cursor still belongs to the previous line, so anchor
74
+ // explicitly on this line's first non-space character.
75
+ this.emit('RAW_TEXT', text, 0);
58
76
  this.emit('NEWLINE', '');
59
77
  continue;
60
78
  }
@@ -196,6 +214,9 @@ class Lexer {
196
214
  case 'if':
197
215
  this.scanIf();
198
216
  break;
217
+ case 'unless':
218
+ this.scanUnless();
219
+ break;
199
220
  case 'for':
200
221
  this.scanFor();
201
222
  break;
@@ -225,70 +246,78 @@ class Lexer {
225
246
  }
226
247
  scanBlock() {
227
248
  this.emit('BLOCK', 'block');
228
- const name = this.currentLine.slice('block '.length).trim();
229
- this.emit('IDENTIFIER', name);
249
+ const nameStart = this.skipSpacesFrom('block '.length);
250
+ const name = this.currentLine.slice(nameStart).trim();
251
+ // A bare 'block' (mixin call-site content) has no name.
252
+ if (name !== '' && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
253
+ const word = name.split(/\s/)[0];
254
+ const message = word === 'append' || word === 'prepend'
255
+ ? `'block ${word}' is not supported; ${APPEND_HINT}`
256
+ : `Block name '${name}' must be a single identifier`;
257
+ throw new LexerError(message, this.lineIndex + 1, this.indentWidth + nameStart + 1);
258
+ }
259
+ this.emit('IDENTIFIER', name, nameStart);
230
260
  this.pos = this.currentLine.length;
231
261
  }
232
262
  scanInclude() {
233
263
  this.emit('INCLUDE', 'include');
234
- const path = this.currentLine.slice('include '.length).trim();
235
- this.emit('PATH', path);
264
+ const pathStart = this.skipSpacesFrom('include '.length);
265
+ const path = this.currentLine.slice(pathStart).trim();
266
+ this.emit('PATH', path, pathStart);
236
267
  this.pos = this.currentLine.length;
237
268
  }
238
269
  // --- Declaration scanners ---
239
270
  scanTemplate() {
271
+ this.emit('TEMPLATE', 'template', 0);
240
272
  this.pos = 'template '.length;
241
- this.emit('TEMPLATE', 'template');
273
+ const nameStart = this.pos;
242
274
  const name = this.scanIdentifier();
243
- this.emit('IDENTIFIER', name);
244
- if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
245
- const params = this.scanBalancedParens();
246
- this.emit('PARAMS', params);
247
- }
275
+ this.emit('IDENTIFIER', name, nameStart);
276
+ this.scanParamsIfPresent();
248
277
  }
249
278
  scanMixin() {
279
+ this.emit('MIXIN', 'mixin', 0);
250
280
  this.pos = 'mixin '.length;
251
- this.emit('MIXIN', 'mixin');
281
+ const nameStart = this.pos;
252
282
  const name = this.scanIdentifier();
253
- this.emit('IDENTIFIER', name);
254
- if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
255
- const params = this.scanBalancedParens();
256
- this.emit('PARAMS', params);
257
- }
283
+ this.emit('IDENTIFIER', name, nameStart);
284
+ this.scanParamsIfPresent();
258
285
  }
259
286
  scanExtends() {
287
+ this.emit('EXTENDS', 'extends', 0);
260
288
  this.pos = 'extends '.length;
261
- this.emit('EXTENDS', 'extends');
289
+ const nameStart = this.pos;
262
290
  const name = this.scanIdentifier();
263
- this.emit('IDENTIFIER', name);
264
- if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
265
- const params = this.scanBalancedParens();
266
- this.emit('PARAMS', params);
267
- }
291
+ this.emit('IDENTIFIER', name, nameStart);
292
+ this.scanParamsIfPresent();
268
293
  }
269
294
  // --- Control flow scanners ---
270
295
  scanIf() {
296
+ this.emit('IF', 'if', 0);
271
297
  this.pos = 'if '.length;
272
- this.emit('IF', 'if');
298
+ const expr = this.currentLine.slice(this.pos);
299
+ this.emit('BUFFERED_CODE', expr);
300
+ this.pos = this.currentLine.length;
301
+ }
302
+ scanUnless() {
303
+ this.emit('UNLESS', 'unless', 0);
304
+ this.pos = 'unless '.length;
273
305
  const expr = this.currentLine.slice(this.pos);
274
306
  this.emit('BUFFERED_CODE', expr);
275
307
  this.pos = this.currentLine.length;
276
308
  }
277
309
  scanElseIf() {
310
+ this.emit('ELSE_IF', 'else if', 0);
278
311
  this.pos = 'else if '.length;
279
- this.emit('ELSE_IF', 'else if');
280
312
  const expr = this.currentLine.slice(this.pos);
281
313
  this.emit('BUFFERED_CODE', expr);
282
314
  this.pos = this.currentLine.length;
283
315
  }
284
316
  scanFor() {
317
+ this.emit('FOR', 'for', 0);
285
318
  this.pos = 'for '.length;
286
- this.emit('FOR', 'for');
287
319
  this.skipSpaces();
288
- if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
289
- const params = this.scanBalancedParens();
290
- this.emit('PARAMS', params);
291
- }
320
+ this.scanParamsIfPresent();
292
321
  }
293
322
  // --- Element scanner ---
294
323
  scanElement() {
@@ -301,8 +330,10 @@ class Lexer {
301
330
  this.emit('TAG', 'div');
302
331
  }
303
332
  else {
333
+ const tagStart = this.pos;
304
334
  const tag = this.scanTagName();
305
- this.emit('TAG', tag);
335
+ this.checkTagName(tag, tagStart);
336
+ this.emit('TAG', tag, tagStart);
306
337
  }
307
338
  // Classes and IDs
308
339
  this.scanClassesAndIds();
@@ -311,11 +342,19 @@ class Lexer {
311
342
  this.emit('ATTR_START', '(');
312
343
  this.pos++; // skip '('
313
344
  this.scanAttributes(elementIndent);
314
- this.emit('ATTR_END', ')');
345
+ this.emit('ATTR_END', ')', this.pos - 1);
315
346
  }
316
347
  // Raw block: tag!
317
348
  if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '!') {
318
349
  this.pos++; // skip !
350
+ // Anything after the '!' used to be dropped without a word, which
351
+ // silently lost the expression in Pug's unescaped 'p!= html'.
352
+ const rest = this.currentLine.slice(this.pos);
353
+ if (rest.trim() !== '') {
354
+ throw new LexerError(rest.startsWith('=')
355
+ ? "Unescaped buffered code ('!=') is not supported; use '= raw(expr)' instead"
356
+ : "A raw block 'tag!' takes no content on its own line; put the raw text on indented lines below it", this.lineIndex + 1, this.indentWidth + this.pos);
357
+ }
319
358
  this.rawBlockIndent = elementIndent;
320
359
  this.rawBlockBaseIndent = null;
321
360
  return;
@@ -336,36 +375,93 @@ class Lexer {
336
375
  }
337
376
  return this.currentLine.slice(start, this.pos);
338
377
  }
378
+ // Rejects tag names that can't be an element. Pug syntax Weave doesn't
379
+ // have used to land here and come out as bogus elements: '- const x = 1'
380
+ // became a '-' element and 'case'/'when' became <case>/<when>.
381
+ checkTagName(tag, tagStart) {
382
+ let message = null;
383
+ if (tag.startsWith('-')) {
384
+ message =
385
+ "Unbuffered code ('- ...') is not supported; templates hold no statements. " +
386
+ "Compute values in TypeScript before rendering, or use a buffered expression 'tag= expr'";
387
+ }
388
+ else if (tag === '') {
389
+ message = `Unexpected '${this.currentLine[tagStart]}'; expected a tag name, '.class' or '#id'`;
390
+ }
391
+ else if (!/^[a-zA-Z]/.test(tag)) {
392
+ message = `Tag name '${tag}' must start with a letter`;
393
+ }
394
+ else if (PUG_KEYWORDS.has(tag)) {
395
+ message = `'${tag}' is not a Weave keyword; ${PUG_KEYWORDS.get(tag)}`;
396
+ }
397
+ if (message !== null) {
398
+ throw new LexerError(message, this.lineIndex + 1, this.indentWidth + tagStart + 1);
399
+ }
400
+ }
339
401
  scanClassesAndIds() {
402
+ let prevClass = null;
340
403
  while (this.pos < this.currentLine.length) {
341
404
  const ch = this.currentLine[this.pos];
342
405
  if (ch === '.') {
406
+ const dotPos = this.pos;
407
+ const dotCol = this.indentWidth + dotPos + 1;
343
408
  this.pos++; // skip dot
344
- const start = this.pos;
345
- while (this.pos < this.currentLine.length) {
346
- const c = this.currentLine[this.pos];
347
- if (c === ' ' || c === '(' || c === '#' || c === '.' || c === '=' || c === '!')
348
- break;
349
- this.pos++;
409
+ const { name, raw } = this.scanShorthandName();
410
+ if (name === '') {
411
+ // Pug's 'p.' block text emitted class="" and then lexed
412
+ // the text lines below it as elements.
413
+ throw new LexerError(this.pos >= this.currentLine.length
414
+ ? "Block text ('tag.') is not supported; use 'tag!' for raw text, or '|' lines for escaped text"
415
+ : "Expected a class name after '.'", this.lineIndex + 1, dotCol);
350
416
  }
351
- this.emit('CLASS', this.currentLine.slice(start, this.pos));
417
+ // A dot is a class separator, so '.gap-1.5' silently became
418
+ // 'gap-1' plus a stray '5'. A class that is a bare number is
419
+ // almost certainly that mistake, so reject it and name the fix.
420
+ if (/^[0-9]+$/.test(name)) {
421
+ const example = prevClass === null ? `.gap-1\\.${name}` : `.${prevClass}\\.${name}`;
422
+ const split = prevClass === null ? '' : ` ('.${prevClass}.${name}' was split into '${prevClass}' and '${name}')`;
423
+ throw new LexerError(`Class '.${name}' is a bare number; a dot inside a class name is read as a separator${split}. ` +
424
+ `Escape the dot with a backslash, e.g. '${example}'`, this.lineIndex + 1, dotCol);
425
+ }
426
+ prevClass = raw;
427
+ this.emit('CLASS', name, dotPos);
352
428
  }
353
429
  else if (ch === '#') {
430
+ const hashPos = this.pos;
354
431
  this.pos++; // skip hash
355
- const start = this.pos;
356
- while (this.pos < this.currentLine.length) {
357
- const c = this.currentLine[this.pos];
358
- if (c === ' ' || c === '(' || c === '#' || c === '.' || c === '=' || c === '!')
359
- break;
360
- this.pos++;
432
+ prevClass = null;
433
+ const { name } = this.scanShorthandName();
434
+ if (name === '') {
435
+ throw new LexerError("Expected an id after '#'", this.lineIndex + 1, this.indentWidth + hashPos + 1);
361
436
  }
362
- this.emit('ID', this.currentLine.slice(start, this.pos));
437
+ this.emit('ID', name, hashPos);
363
438
  }
364
439
  else {
365
440
  break;
366
441
  }
367
442
  }
368
443
  }
444
+ // Scans a class or id name after its '.' or '#'. A backslash-escaped dot
445
+ // ('\.') is a literal dot rather than the start of the next class, so
446
+ // Tailwind's 'gap-1\.5' is a single class 'gap-1.5'. Returns the unescaped
447
+ // name and the source text it came from.
448
+ scanShorthandName() {
449
+ const start = this.pos;
450
+ let name = '';
451
+ while (this.pos < this.currentLine.length) {
452
+ const c = this.currentLine[this.pos];
453
+ if (c === '\\' && this.currentLine[this.pos + 1] === '.') {
454
+ name += '.';
455
+ this.pos += 2;
456
+ continue;
457
+ }
458
+ if (c === ' ' || c === '(' || c === '#' || c === '.' || c === '=' || c === '!')
459
+ break;
460
+ name += c;
461
+ this.pos++;
462
+ }
463
+ return { name, raw: this.currentLine.slice(start, this.pos) };
464
+ }
369
465
  // Scans an attribute list, with the cursor just past the opening '('.
370
466
  // `ownerIndent` is the indent of the element or mixin call that owns the
371
467
  // list, which bounds the multi-line form.
@@ -461,6 +557,7 @@ class Lexer {
461
557
  // Scans a single `...spread`, `name` or `name=value`, with the cursor on
462
558
  // its first character.
463
559
  scanAttribute() {
560
+ const start = this.pos;
464
561
  // Spread: ...identifier
465
562
  if (this.currentLine[this.pos] === '.' &&
466
563
  this.pos + 2 < this.currentLine.length &&
@@ -468,7 +565,7 @@ class Lexer {
468
565
  this.currentLine[this.pos + 2] === '.') {
469
566
  this.pos += 3; // skip ...
470
567
  const name = this.scanIdentifier();
471
- this.emit('SPREAD', name);
568
+ this.emit('SPREAD', name, start);
472
569
  return;
473
570
  }
474
571
  // Attribute name
@@ -480,14 +577,15 @@ class Lexer {
480
577
  // Without this guard the caller's loop spins forever emitting
481
578
  // empty ATTR_NAME tokens until the process aborts.
482
579
  throw new LexerError(`Unexpected '${this.currentLine[this.pos]}' in attribute list; ` +
483
- `wrap complex attribute values in (parentheses) or \`backticks\``, this.lineIndex + 1, this.indentWidth + this.pos + 1);
580
+ 'wrap complex attribute values in (parentheses) or `backticks`', this.lineIndex + 1, this.indentWidth + this.pos + 1);
484
581
  }
485
- this.emit('ATTR_NAME', name);
582
+ this.emit('ATTR_NAME', name, start);
486
583
  // Check for = value
487
584
  if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '=') {
488
585
  this.pos++; // skip =
586
+ const valueStart = this.pos;
489
587
  const value = this.scanAttrValue();
490
- this.emit('ATTR_VALUE', value);
588
+ this.emit('ATTR_VALUE', value, valueStart);
491
589
  }
492
590
  }
493
591
  scanAttrName() {
@@ -565,25 +663,49 @@ class Lexer {
565
663
  this.pos = this.currentLine.length;
566
664
  return;
567
665
  }
666
+ // Anything else used to be dropped along with the rest of the line,
667
+ // e.g. the child in Pug's block expansion 'li: a(href="/") Home'.
668
+ let message;
669
+ if (ch === ':') {
670
+ message =
671
+ "Block expansion ('tag: child') is not supported; " +
672
+ 'put the child element on its own indented line';
673
+ }
674
+ else if (ch === '/') {
675
+ message = "Self-closing 'tag/' is not supported; void elements like 'br' and 'img' close themselves";
676
+ }
677
+ else {
678
+ message = `Unexpected '${ch}' after element; separate inline text from the tag with a space`;
679
+ }
680
+ throw new LexerError(message, this.lineIndex + 1, this.indentWidth + this.pos + 1);
568
681
  }
569
682
  // --- Mixin call scanner ---
570
683
  scanMixinCall() {
571
684
  const elementIndent = this.indentWidth;
572
685
  this.pos = 1; // skip +
573
686
  const name = this.scanIdentifier();
574
- this.emit('MIXIN_CALL', name);
687
+ // Anchored on the '+', not where the name scan left the cursor.
688
+ this.emit('MIXIN_CALL', name, 0);
575
689
  // Args in first set of parens
576
- if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
577
- const args = this.scanBalancedParens();
578
- this.emit('PARAMS', args);
579
- }
690
+ this.scanParamsIfPresent();
580
691
  // Extra attrs in second set of parens
581
692
  if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
582
693
  this.emit('ATTR_START', '(');
583
694
  this.pos++; // skip '('
584
695
  this.scanAttributes(elementIndent);
585
- this.emit('ATTR_END', ')');
696
+ this.emit('ATTR_END', ')', this.pos - 1);
697
+ }
698
+ // One line of call-site content may follow, as for an element:
699
+ // '+btn("x") Save' or '+btn("x")= label'. Trailing spaces alone are
700
+ // not content.
701
+ const rest = this.currentLine.slice(this.pos);
702
+ if (rest.trim() === '')
703
+ return;
704
+ if (rest[0] === ' ' || rest[0] === '=') {
705
+ this.scanTrailingContent();
706
+ return;
586
707
  }
708
+ throw new LexerError(`Unexpected '${rest[0]}' after mixin call; separate inline content from the call with a space`, this.lineIndex + 1, this.indentWidth + this.pos + 1);
587
709
  }
588
710
  // --- Pipe scanner ---
589
711
  scanPipe() {
@@ -599,17 +721,31 @@ class Lexer {
599
721
  this.pos = this.currentLine.length;
600
722
  }
601
723
  // --- Text with interpolation ---
724
+ // `text` is the rest of the current line from the cursor, so an offset
725
+ // into it plus the cursor is that character's position on the line.
602
726
  scanTextWithInterpolation(text) {
603
727
  let i = 0;
604
728
  let buf = '';
729
+ let bufStart = 0;
605
730
  while (i < text.length) {
731
+ // Pug's tag interpolation '#[strong x]' and unescaped '!{html}'
732
+ // used to pass through as literal text, so the markup characters
733
+ // rendered (escaped) on the page.
734
+ if ((text[i] === '#' && text[i + 1] === '[') || (text[i] === '!' && text[i + 1] === '{')) {
735
+ throw new LexerError((text[i] === '#'
736
+ ? "Tag interpolation ('#[tag ...]') is not supported; put the element on its own " +
737
+ "indented line, with '|' lines for the text around it"
738
+ : "Unescaped interpolation ('!{...}') is not supported; use '#{raw(expr)}' instead") +
739
+ `. For literal '${text.slice(i, i + 2)}' text, use a string expression: 'tag= "..."'`, this.lineIndex + 1, this.indentWidth + this.pos + i + 1);
740
+ }
606
741
  // Check for interpolation #{...}
607
742
  if (text[i] === '#' && i + 1 < text.length && text[i + 1] === '{') {
608
743
  // Emit accumulated text
609
744
  if (buf.length > 0) {
610
- this.emit('TEXT', buf);
745
+ this.emit('TEXT', buf, this.pos + bufStart);
611
746
  buf = '';
612
747
  }
748
+ const interpStart = i;
613
749
  i += 2; // skip #{
614
750
  // Scan balanced braces to find closing }
615
751
  let depth = 1;
@@ -641,10 +777,11 @@ class Lexer {
641
777
  i++;
642
778
  }
643
779
  if (depth !== 0) {
644
- throw new LexerError('Unterminated interpolation', this.lineIndex + 1, this.indentWidth + this.pos + 1);
780
+ throw new LexerError('Unterminated interpolation', this.lineIndex + 1, this.indentWidth + this.pos + interpStart + 1);
645
781
  }
646
- this.emit('INTERPOLATION', text.slice(start, i));
782
+ this.emit('INTERPOLATION', text.slice(start, i), this.pos + interpStart);
647
783
  i++; // skip closing }
784
+ bufStart = i;
648
785
  }
649
786
  else {
650
787
  buf += text[i];
@@ -653,42 +790,125 @@ class Lexer {
653
790
  }
654
791
  // Emit remaining text
655
792
  if (buf.length > 0) {
656
- this.emit('TEXT', buf);
793
+ this.emit('TEXT', buf, this.pos + bufStart);
657
794
  }
658
795
  }
659
796
  // --- Balanced scanning helpers ---
797
+ // Scans a '(...)' group whose contents are captured verbatim (params, for
798
+ // headers, mixin call args), with the cursor on the '('. Returns the text
799
+ // between the parens.
800
+ //
801
+ // The group may span lines: it simply stays open until its parens balance.
802
+ // Unlike a multi-line attribute list there is no required shape, so the
803
+ // way TypeScript is usually wrapped works as written:
804
+ //
805
+ // template dashboard({
806
+ // title,
807
+ // }: DashboardProps)
808
+ //
809
+ // Continuation lines are consumed here, so they never reach the main loop
810
+ // and produce no INDENT/DEDENT/NEWLINE tokens. They keep their indentation
811
+ // relative to the line that opened the group. `//` comments are dropped:
812
+ // the captured text is re-emitted inside a single expression, where a
813
+ // comment would swallow whatever the compiler appends after it.
660
814
  scanBalancedParens() {
661
815
  if (this.currentLine[this.pos] !== '(') {
662
816
  throw new LexerError('Expected opening parenthesis', this.lineIndex + 1, this.indentWidth + this.pos + 1);
663
817
  }
818
+ const ownerIndent = this.indentWidth;
819
+ const openLine = this.lineIndex + 1;
820
+ const openCol = this.indentWidth + this.pos + 1;
664
821
  this.pos++; // skip opening (
665
822
  let depth = 1;
666
- const start = this.pos;
667
- while (this.pos < this.currentLine.length && depth > 0) {
668
- const ch = this.currentLine[this.pos];
669
- if (ch === '(') {
670
- depth++;
671
- this.pos++;
672
- }
673
- else if (ch === ')') {
674
- depth--;
675
- if (depth === 0)
676
- break;
677
- this.pos++;
823
+ const pieces = [];
824
+ const starts = [];
825
+ let prefix = ''; // this line's indentation relative to the owner
826
+ for (;;) {
827
+ const start = this.pos;
828
+ let end = -1; // where this line's content stops: the closing ')' or a comment
829
+ while (this.pos < this.currentLine.length) {
830
+ const ch = this.currentLine[this.pos];
831
+ if (ch === '(') {
832
+ depth++;
833
+ this.pos++;
834
+ }
835
+ else if (ch === ')') {
836
+ depth--;
837
+ if (depth === 0) {
838
+ end = this.pos;
839
+ break;
840
+ }
841
+ this.pos++;
842
+ }
843
+ else if (ch === '"' || ch === "'" || ch === '`') {
844
+ const quotePos = this.pos;
845
+ if (!this.skipStringInPlace(ch)) {
846
+ // A string has to close on its own line; letting it run
847
+ // on would throw the paren count off for every line after.
848
+ throw new LexerError('Unterminated string', this.lineIndex + 1, this.indentWidth + quotePos + 1);
849
+ }
850
+ }
851
+ else if (ch === '/' && this.currentLine[this.pos + 1] === '/') {
852
+ end = this.pos;
853
+ this.pos = this.currentLine.length;
854
+ }
855
+ else {
856
+ this.pos++;
857
+ }
678
858
  }
679
- else if (ch === '"' || ch === "'" || ch === '`') {
680
- this.skipStringInPlace(ch);
859
+ const isComment = end !== -1 && depth > 0;
860
+ const text = this.currentLine.slice(start, end === -1 ? this.currentLine.length : end).trimEnd();
861
+ const line = this.lineIndex + 1;
862
+ if (pieces.length === 0) {
863
+ const lead = text.length - text.trimStart().length;
864
+ starts.push({ line, col: this.indentWidth + start + lead + 1 });
865
+ if (depth === 0) {
866
+ // Closed on the line it opened on: the common, single-line case.
867
+ this.pos++; // skip closing )
868
+ return { value: text.trim(), starts };
869
+ }
870
+ pieces.push(text.trimStart());
681
871
  }
682
- else {
683
- this.pos++;
872
+ else if (text !== '' || !isComment) {
873
+ // Lines holding nothing but a comment are dropped entirely.
874
+ // A kept line is `prefix + text`, so it starts at the owner's indent.
875
+ pieces.push(text === '' ? '' : prefix + text);
876
+ starts.push({ line, col: ownerIndent + 1 });
684
877
  }
878
+ if (depth === 0)
879
+ break;
880
+ this.nextParamsLine(ownerIndent, openLine, openCol);
881
+ prefix = ' '.repeat(this.indentWidth - ownerIndent);
685
882
  }
686
- if (depth !== 0) {
687
- throw new LexerError('Unterminated parentheses', this.lineIndex + 1, this.indentWidth + this.pos + 1);
688
- }
689
- const content = this.currentLine.slice(start, this.pos);
690
883
  this.pos++; // skip closing )
691
- return content.trim();
884
+ const content = pieces.join('\n');
885
+ return content.trim() === '' ? { value: '', starts: [] } : { value: content, starts };
886
+ }
887
+ // Moves onto the next non-blank line of a multi-line paren group. Lines in
888
+ // the group must be indented under its owner; the one exception is the
889
+ // line that closes it (e.g. '}: Props)'), which may sit level with the
890
+ // owner as long as it starts with a closing bracket. Anything else at or
891
+ // left of the owner means the ')' was forgotten, so stop there rather
892
+ // than swallowing the rest of the file.
893
+ nextParamsLine(ownerIndent, openLine, openCol) {
894
+ for (;;) {
895
+ if (this.lineIndex + 1 >= this.lines.length) {
896
+ throw new LexerError('Unterminated parentheses', openLine, openCol);
897
+ }
898
+ this.lineIndex++;
899
+ const rawLine = this.lines[this.lineIndex];
900
+ if (rawLine.trim().length === 0)
901
+ continue;
902
+ this.indentWidth = this.measureIndent(rawLine);
903
+ this.currentLine = rawLine.slice(this.indentWidth);
904
+ this.pos = 0;
905
+ if (this.indentWidth < ownerIndent ||
906
+ (this.indentWidth === ownerIndent && !/^[)}\]]/.test(this.currentLine))) {
907
+ throw new LexerError(`Expected ')' to close the parentheses opened at line ${openLine}; ` +
908
+ 'continuation lines must be indented under the line that opens them', this.lineIndex + 1, this.indentWidth + 1);
909
+ }
910
+ return;
911
+ }
692
912
  }
693
913
  scanString(quote) {
694
914
  const start = this.pos;
@@ -745,7 +965,10 @@ class Lexer {
745
965
  }
746
966
  throw new LexerError('Unterminated template literal', this.lineIndex + 1, this.indentWidth + this.pos + 1);
747
967
  }
748
- /** Skip over a string in-place (used within balanced paren scanning) */
968
+ /**
969
+ * Skip over a string in-place (used within balanced paren scanning).
970
+ * Returns false when the line ends before the string closes.
971
+ */
749
972
  skipStringInPlace(quote) {
750
973
  this.pos++; // skip opening quote
751
974
  while (this.pos < this.currentLine.length) {
@@ -754,7 +977,7 @@ class Lexer {
754
977
  }
755
978
  else if (this.currentLine[this.pos] === quote) {
756
979
  this.pos++;
757
- return;
980
+ return true;
758
981
  }
759
982
  else if (quote === '`' &&
760
983
  this.currentLine[this.pos] === '$' &&
@@ -764,21 +987,27 @@ class Lexer {
764
987
  this.pos += 2;
765
988
  let depth = 1;
766
989
  while (this.pos < this.currentLine.length && depth > 0) {
767
- if (this.currentLine[this.pos] === '{')
990
+ if (this.currentLine[this.pos] === '{') {
768
991
  depth++;
769
- else if (this.currentLine[this.pos] === '}')
992
+ }
993
+ else if (this.currentLine[this.pos] === '}') {
770
994
  depth--;
771
- if (depth > 0)
772
- this.pos++;
995
+ }
996
+ else if (this.currentLine[this.pos] === '"' ||
997
+ this.currentLine[this.pos] === "'" ||
998
+ this.currentLine[this.pos] === '`') {
999
+ this.skipStringInPlace(this.currentLine[this.pos]);
1000
+ continue;
1001
+ }
1002
+ this.pos++;
773
1003
  }
774
- if (this.pos < this.currentLine.length)
775
- this.pos++; // skip closing }
776
- return;
1004
+ // fall through to keep scanning for the closing backtick
777
1005
  }
778
1006
  else {
779
1007
  this.pos++;
780
1008
  }
781
1009
  }
1010
+ return false;
782
1011
  }
783
1012
  // --- Utility methods ---
784
1013
  scanIdentifier() {
@@ -794,17 +1023,36 @@ class Lexer {
794
1023
  }
795
1024
  return this.currentLine.slice(start, this.pos);
796
1025
  }
1026
+ // Scans a '(...)' group at the cursor, if there is one, as a PARAMS token
1027
+ // anchored on the '('. The group may run onto later lines, so the anchor
1028
+ // is taken before scanning moves the cursor.
1029
+ scanParamsIfPresent() {
1030
+ if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
1031
+ const line = this.lineIndex + 1;
1032
+ const col = this.indentWidth + this.pos + 1;
1033
+ const { value, starts } = this.scanBalancedParens();
1034
+ this.tokens.push({ type: 'PARAMS', value, line, col, starts });
1035
+ }
1036
+ }
1037
+ // Returns the first non-space position at or after `pos` on the current line.
1038
+ skipSpacesFrom(pos) {
1039
+ while (pos < this.currentLine.length && this.currentLine[pos] === ' ')
1040
+ pos++;
1041
+ return pos;
1042
+ }
797
1043
  skipSpaces() {
798
1044
  while (this.pos < this.currentLine.length && this.currentLine[this.pos] === ' ') {
799
1045
  this.pos++;
800
1046
  }
801
1047
  }
802
- emit(type, value) {
1048
+ // `start` is the token's position on the current line; it defaults to the
1049
+ // cursor, which for scanned tokens is already past the token itself.
1050
+ emit(type, value, start = this.pos) {
803
1051
  this.tokens.push({
804
1052
  type,
805
1053
  value,
806
1054
  line: this.lineIndex + 1,
807
- col: this.indentWidth + this.pos + 1,
1055
+ col: this.indentWidth + start + 1,
808
1056
  });
809
1057
  }
810
1058
  }