@chilts/weave 0.5.0 → 0.7.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.
@@ -54,7 +54,9 @@ class Lexer {
54
54
  }
55
55
  // Preserve indentation relative to the base
56
56
  const text = rawLine.slice(this.rawBlockBaseIndent);
57
- this.emit('RAW_TEXT', text);
57
+ // The cursor still belongs to the previous line, so anchor
58
+ // explicitly on this line's first non-space character.
59
+ this.emit('RAW_TEXT', text, 0);
58
60
  this.emit('NEWLINE', '');
59
61
  continue;
60
62
  }
@@ -225,81 +227,78 @@ class Lexer {
225
227
  }
226
228
  scanBlock() {
227
229
  this.emit('BLOCK', 'block');
228
- const name = this.currentLine.slice('block '.length).trim();
229
- this.emit('IDENTIFIER', name);
230
+ const nameStart = this.skipSpacesFrom('block '.length);
231
+ const name = this.currentLine.slice(nameStart).trim();
232
+ this.emit('IDENTIFIER', name, nameStart);
230
233
  this.pos = this.currentLine.length;
231
234
  }
232
235
  scanInclude() {
233
236
  this.emit('INCLUDE', 'include');
234
- const path = this.currentLine.slice('include '.length).trim();
235
- this.emit('PATH', path);
237
+ const pathStart = this.skipSpacesFrom('include '.length);
238
+ const path = this.currentLine.slice(pathStart).trim();
239
+ this.emit('PATH', path, pathStart);
236
240
  this.pos = this.currentLine.length;
237
241
  }
238
242
  // --- Declaration scanners ---
239
243
  scanTemplate() {
244
+ this.emit('TEMPLATE', 'template', 0);
240
245
  this.pos = 'template '.length;
241
- this.emit('TEMPLATE', 'template');
246
+ const nameStart = this.pos;
242
247
  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
- }
248
+ this.emit('IDENTIFIER', name, nameStart);
249
+ this.scanParamsIfPresent();
248
250
  }
249
251
  scanMixin() {
252
+ this.emit('MIXIN', 'mixin', 0);
250
253
  this.pos = 'mixin '.length;
251
- this.emit('MIXIN', 'mixin');
254
+ const nameStart = this.pos;
252
255
  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
- }
256
+ this.emit('IDENTIFIER', name, nameStart);
257
+ this.scanParamsIfPresent();
258
258
  }
259
259
  scanExtends() {
260
+ this.emit('EXTENDS', 'extends', 0);
260
261
  this.pos = 'extends '.length;
261
- this.emit('EXTENDS', 'extends');
262
+ const nameStart = this.pos;
262
263
  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
- }
264
+ this.emit('IDENTIFIER', name, nameStart);
265
+ this.scanParamsIfPresent();
268
266
  }
269
267
  // --- Control flow scanners ---
270
268
  scanIf() {
269
+ this.emit('IF', 'if', 0);
271
270
  this.pos = 'if '.length;
272
- this.emit('IF', 'if');
273
271
  const expr = this.currentLine.slice(this.pos);
274
272
  this.emit('BUFFERED_CODE', expr);
275
273
  this.pos = this.currentLine.length;
276
274
  }
277
275
  scanElseIf() {
276
+ this.emit('ELSE_IF', 'else if', 0);
278
277
  this.pos = 'else if '.length;
279
- this.emit('ELSE_IF', 'else if');
280
278
  const expr = this.currentLine.slice(this.pos);
281
279
  this.emit('BUFFERED_CODE', expr);
282
280
  this.pos = this.currentLine.length;
283
281
  }
284
282
  scanFor() {
283
+ this.emit('FOR', 'for', 0);
285
284
  this.pos = 'for '.length;
286
- this.emit('FOR', 'for');
287
285
  this.skipSpaces();
288
- if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
289
- const params = this.scanBalancedParens();
290
- this.emit('PARAMS', params);
291
- }
286
+ this.scanParamsIfPresent();
292
287
  }
293
288
  // --- Element scanner ---
294
289
  scanElement() {
295
290
  const ch = this.currentLine[this.pos];
291
+ // Captured up front: a multi-line attribute list moves the cursor onto
292
+ // later lines and updates this.indentWidth as it goes.
293
+ const elementIndent = this.indentWidth;
296
294
  // Tag name or implicit div
297
295
  if (ch === '.' || ch === '#') {
298
296
  this.emit('TAG', 'div');
299
297
  }
300
298
  else {
299
+ const tagStart = this.pos;
301
300
  const tag = this.scanTagName();
302
- this.emit('TAG', tag);
301
+ this.emit('TAG', tag, tagStart);
303
302
  }
304
303
  // Classes and IDs
305
304
  this.scanClassesAndIds();
@@ -307,13 +306,13 @@ class Lexer {
307
306
  if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
308
307
  this.emit('ATTR_START', '(');
309
308
  this.pos++; // skip '('
310
- this.scanAttributes();
311
- this.emit('ATTR_END', ')');
309
+ this.scanAttributes(elementIndent);
310
+ this.emit('ATTR_END', ')', this.pos - 1);
312
311
  }
313
312
  // Raw block: tag!
314
313
  if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '!') {
315
314
  this.pos++; // skip !
316
- this.rawBlockIndent = this.indentWidth;
315
+ this.rawBlockIndent = elementIndent;
317
316
  this.rawBlockBaseIndent = null;
318
317
  return;
319
318
  }
@@ -334,36 +333,75 @@ class Lexer {
334
333
  return this.currentLine.slice(start, this.pos);
335
334
  }
336
335
  scanClassesAndIds() {
336
+ let prevClass = null;
337
337
  while (this.pos < this.currentLine.length) {
338
338
  const ch = this.currentLine[this.pos];
339
339
  if (ch === '.') {
340
+ const dotPos = this.pos;
341
+ const dotCol = this.indentWidth + dotPos + 1;
340
342
  this.pos++; // skip dot
341
- const start = this.pos;
342
- while (this.pos < this.currentLine.length) {
343
- const c = this.currentLine[this.pos];
344
- if (c === ' ' || c === '(' || c === '#' || c === '.' || c === '=' || c === '!')
345
- break;
346
- this.pos++;
343
+ const { name, raw } = this.scanShorthandName();
344
+ // A dot is a class separator, so '.gap-1.5' silently became
345
+ // 'gap-1' plus a stray '5'. A class that is a bare number is
346
+ // almost certainly that mistake, so reject it and name the fix.
347
+ if (/^[0-9]+$/.test(name)) {
348
+ const example = prevClass === null ? `.gap-1\\.${name}` : `.${prevClass}\\.${name}`;
349
+ const split = prevClass === null ? '' : ` ('.${prevClass}.${name}' was split into '${prevClass}' and '${name}')`;
350
+ throw new LexerError(`Class '.${name}' is a bare number; a dot inside a class name is read as a separator${split}. ` +
351
+ `Escape the dot with a backslash, e.g. '${example}'`, this.lineIndex + 1, dotCol);
347
352
  }
348
- this.emit('CLASS', this.currentLine.slice(start, this.pos));
353
+ prevClass = raw;
354
+ this.emit('CLASS', name, dotPos);
349
355
  }
350
356
  else if (ch === '#') {
357
+ const hashPos = this.pos;
351
358
  this.pos++; // skip hash
352
- const start = this.pos;
353
- while (this.pos < this.currentLine.length) {
354
- const c = this.currentLine[this.pos];
355
- if (c === ' ' || c === '(' || c === '#' || c === '.' || c === '=' || c === '!')
356
- break;
357
- this.pos++;
358
- }
359
- this.emit('ID', this.currentLine.slice(start, this.pos));
359
+ prevClass = null;
360
+ this.emit('ID', this.scanShorthandName().name, hashPos);
360
361
  }
361
362
  else {
362
363
  break;
363
364
  }
364
365
  }
365
366
  }
366
- scanAttributes() {
367
+ // Scans a class or id name after its '.' or '#'. A backslash-escaped dot
368
+ // ('\.') is a literal dot rather than the start of the next class, so
369
+ // Tailwind's 'gap-1\.5' is a single class 'gap-1.5'. Returns the unescaped
370
+ // name and the source text it came from.
371
+ scanShorthandName() {
372
+ const start = this.pos;
373
+ let name = '';
374
+ while (this.pos < this.currentLine.length) {
375
+ const c = this.currentLine[this.pos];
376
+ if (c === '\\' && this.currentLine[this.pos + 1] === '.') {
377
+ name += '.';
378
+ this.pos += 2;
379
+ continue;
380
+ }
381
+ if (c === ' ' || c === '(' || c === '#' || c === '.' || c === '=' || c === '!')
382
+ break;
383
+ name += c;
384
+ this.pos++;
385
+ }
386
+ return { name, raw: this.currentLine.slice(start, this.pos) };
387
+ }
388
+ // Scans an attribute list, with the cursor just past the opening '('.
389
+ // `ownerIndent` is the indent of the element or mixin call that owns the
390
+ // list, which bounds the multi-line form.
391
+ //
392
+ // Two shapes are accepted:
393
+ // - inline: the whole list lives on one line and ends with ')'
394
+ // - multi-line: the '(' is the last non-space character on its line,
395
+ // one attribute per line follows, and a ')' starting its
396
+ // own line closes the list
397
+ scanAttributes(ownerIndent) {
398
+ const openLine = this.lineIndex + 1;
399
+ const openCol = this.indentWidth + this.pos; // the '(' we just stepped over
400
+ // A '(' with nothing after it opens a multi-line attribute list.
401
+ if (this.currentLine.slice(this.pos).trim() === '') {
402
+ this.scanAttributeBlock(ownerIndent, openLine, openCol);
403
+ return;
404
+ }
367
405
  while (this.pos < this.currentLine.length) {
368
406
  this.skipSpaces();
369
407
  if (this.pos >= this.currentLine.length)
@@ -372,26 +410,106 @@ class Lexer {
372
410
  this.pos++; // skip closing paren
373
411
  return;
374
412
  }
375
- // Spread: ...identifier
376
- if (this.currentLine[this.pos] === '.' &&
377
- this.pos + 2 < this.currentLine.length &&
378
- this.currentLine[this.pos + 1] === '.' &&
379
- this.currentLine[this.pos + 2] === '.') {
380
- this.pos += 3; // skip ...
381
- const name = this.scanIdentifier();
382
- this.emit('SPREAD', name);
413
+ this.scanAttribute();
414
+ }
415
+ // Running off the end of the line without a ')' used to be accepted
416
+ // silently: the following lines were then lexed as child elements, so
417
+ // attribute names became tags and their values became visible text.
418
+ throw new LexerError("Unterminated attribute list; expected ')' before the end of the line. " +
419
+ 'To split attributes over several lines, end the line with the ' +
420
+ "opening '(' and close with a ')' on its own line", openLine, openCol);
421
+ }
422
+ // Consumes the lines of a multi-line attribute list, up to and including
423
+ // the line whose first non-space character is the closing ')'. Those lines
424
+ // never reach the main loop, so they produce no INDENT/DEDENT/NEWLINE
425
+ // tokens and the resulting token stream is identical to the inline form.
426
+ scanAttributeBlock(ownerIndent, openLine, openCol) {
427
+ while (this.lineIndex + 1 < this.lines.length) {
428
+ this.lineIndex++;
429
+ const rawLine = this.lines[this.lineIndex];
430
+ // Blank lines may separate groups of attributes
431
+ if (rawLine.trim().length === 0)
383
432
  continue;
433
+ this.indentWidth = this.measureIndent(rawLine);
434
+ this.currentLine = rawLine.slice(this.indentWidth);
435
+ this.pos = 0;
436
+ // A ')' starting the line closes the list; the rest of that line is
437
+ // trailing content for the owning element, exactly as if the whole
438
+ // list had been written inline.
439
+ if (this.currentLine[0] === ')') {
440
+ this.pos = 1;
441
+ return;
384
442
  }
385
- // Attribute name
386
- const name = this.scanAttrName();
387
- this.emit('ATTR_NAME', name);
388
- // Check for = value
389
- if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '=') {
390
- this.pos++; // skip =
391
- const value = this.scanAttrValue();
392
- this.emit('ATTR_VALUE', value);
443
+ if (this.indentWidth <= ownerIndent) {
444
+ throw new LexerError(`Expected ')' to close the attribute list opened at line ${openLine}; ` +
445
+ 'attributes in a multi-line list must be indented under their element', this.lineIndex + 1, this.indentWidth + 1);
446
+ }
447
+ this.skipSpaces();
448
+ this.scanAttribute();
449
+ // One attribute per line. This is stricter than the inline form on
450
+ // purpose: it keeps the door open to letting multi-token bare values
451
+ // (ternaries, concatenation) go unwrapped here later, which only
452
+ // works while the newline is the separator.
453
+ this.skipSpaces();
454
+ if (this.pos < this.currentLine.length) {
455
+ const rest = this.currentLine.slice(this.pos);
456
+ const ch = rest[0];
457
+ let message;
458
+ if (ch === ')') {
459
+ message =
460
+ "')' must start its own line to close the attribute list " +
461
+ `opened at line ${openLine}`;
462
+ }
463
+ else if (/^[a-zA-Z_]/.test(rest) || rest.startsWith('...')) {
464
+ message =
465
+ 'Only one attribute is allowed per line in a multi-line ' +
466
+ `attribute list (opened at line ${openLine})`;
467
+ }
468
+ else {
469
+ // Leftovers that can't start another attribute are almost
470
+ // always the tail of a multi-token bare value.
471
+ message =
472
+ `Unexpected '${ch}' in attribute list; ` +
473
+ 'wrap complex attribute values in (parentheses) or `backticks`';
474
+ }
475
+ throw new LexerError(message, this.lineIndex + 1, this.indentWidth + this.pos + 1);
393
476
  }
394
477
  }
478
+ throw new LexerError("Unterminated attribute list; expected a ')' on its own line", openLine, openCol);
479
+ }
480
+ // Scans a single `...spread`, `name` or `name=value`, with the cursor on
481
+ // its first character.
482
+ scanAttribute() {
483
+ const start = this.pos;
484
+ // Spread: ...identifier
485
+ if (this.currentLine[this.pos] === '.' &&
486
+ this.pos + 2 < this.currentLine.length &&
487
+ this.currentLine[this.pos + 1] === '.' &&
488
+ this.currentLine[this.pos + 2] === '.') {
489
+ this.pos += 3; // skip ...
490
+ const name = this.scanIdentifier();
491
+ this.emit('SPREAD', name, start);
492
+ return;
493
+ }
494
+ // Attribute name
495
+ const name = this.scanAttrName();
496
+ if (name === '') {
497
+ // scanAttrName made no progress: the cursor is parked on a
498
+ // character that can't start an attribute name (e.g. the `?`,
499
+ // `:`, `+` or quote of a multi-token bare attribute value).
500
+ // Without this guard the caller's loop spins forever emitting
501
+ // empty ATTR_NAME tokens until the process aborts.
502
+ throw new LexerError(`Unexpected '${this.currentLine[this.pos]}' in attribute list; ` +
503
+ `wrap complex attribute values in (parentheses) or \`backticks\``, this.lineIndex + 1, this.indentWidth + this.pos + 1);
504
+ }
505
+ this.emit('ATTR_NAME', name, start);
506
+ // Check for = value
507
+ if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '=') {
508
+ this.pos++; // skip =
509
+ const valueStart = this.pos;
510
+ const value = this.scanAttrValue();
511
+ this.emit('ATTR_VALUE', value, valueStart);
512
+ }
395
513
  }
396
514
  scanAttrName() {
397
515
  const start = this.pos;
@@ -471,20 +589,19 @@ class Lexer {
471
589
  }
472
590
  // --- Mixin call scanner ---
473
591
  scanMixinCall() {
592
+ const elementIndent = this.indentWidth;
474
593
  this.pos = 1; // skip +
475
594
  const name = this.scanIdentifier();
476
- this.emit('MIXIN_CALL', name);
595
+ // Anchored on the '+', not where the name scan left the cursor.
596
+ this.emit('MIXIN_CALL', name, 0);
477
597
  // Args in first set of parens
478
- if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
479
- const args = this.scanBalancedParens();
480
- this.emit('PARAMS', args);
481
- }
598
+ this.scanParamsIfPresent();
482
599
  // Extra attrs in second set of parens
483
600
  if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
484
601
  this.emit('ATTR_START', '(');
485
602
  this.pos++; // skip '('
486
- this.scanAttributes();
487
- this.emit('ATTR_END', ')');
603
+ this.scanAttributes(elementIndent);
604
+ this.emit('ATTR_END', ')', this.pos - 1);
488
605
  }
489
606
  }
490
607
  // --- Pipe scanner ---
@@ -501,17 +618,21 @@ class Lexer {
501
618
  this.pos = this.currentLine.length;
502
619
  }
503
620
  // --- Text with interpolation ---
621
+ // `text` is the rest of the current line from the cursor, so an offset
622
+ // into it plus the cursor is that character's position on the line.
504
623
  scanTextWithInterpolation(text) {
505
624
  let i = 0;
506
625
  let buf = '';
626
+ let bufStart = 0;
507
627
  while (i < text.length) {
508
628
  // Check for interpolation #{...}
509
629
  if (text[i] === '#' && i + 1 < text.length && text[i + 1] === '{') {
510
630
  // Emit accumulated text
511
631
  if (buf.length > 0) {
512
- this.emit('TEXT', buf);
632
+ this.emit('TEXT', buf, this.pos + bufStart);
513
633
  buf = '';
514
634
  }
635
+ const interpStart = i;
515
636
  i += 2; // skip #{
516
637
  // Scan balanced braces to find closing }
517
638
  let depth = 1;
@@ -543,10 +664,11 @@ class Lexer {
543
664
  i++;
544
665
  }
545
666
  if (depth !== 0) {
546
- throw new LexerError('Unterminated interpolation', this.lineIndex + 1, this.indentWidth + this.pos + 1);
667
+ throw new LexerError('Unterminated interpolation', this.lineIndex + 1, this.indentWidth + this.pos + interpStart + 1);
547
668
  }
548
- this.emit('INTERPOLATION', text.slice(start, i));
669
+ this.emit('INTERPOLATION', text.slice(start, i), this.pos + interpStart);
549
670
  i++; // skip closing }
671
+ bufStart = i;
550
672
  }
551
673
  else {
552
674
  buf += text[i];
@@ -555,42 +677,119 @@ class Lexer {
555
677
  }
556
678
  // Emit remaining text
557
679
  if (buf.length > 0) {
558
- this.emit('TEXT', buf);
680
+ this.emit('TEXT', buf, this.pos + bufStart);
559
681
  }
560
682
  }
561
683
  // --- Balanced scanning helpers ---
684
+ // Scans a '(...)' group whose contents are captured verbatim (params, for
685
+ // headers, mixin call args), with the cursor on the '('. Returns the text
686
+ // between the parens.
687
+ //
688
+ // The group may span lines: it simply stays open until its parens balance.
689
+ // Unlike a multi-line attribute list there is no required shape, so the
690
+ // way TypeScript is usually wrapped works as written:
691
+ //
692
+ // template dashboard({
693
+ // title,
694
+ // }: DashboardProps)
695
+ //
696
+ // Continuation lines are consumed here, so they never reach the main loop
697
+ // and produce no INDENT/DEDENT/NEWLINE tokens. They keep their indentation
698
+ // relative to the line that opened the group. `//` comments are dropped:
699
+ // the captured text is re-emitted inside a single expression, where a
700
+ // comment would swallow whatever the compiler appends after it.
562
701
  scanBalancedParens() {
563
702
  if (this.currentLine[this.pos] !== '(') {
564
703
  throw new LexerError('Expected opening parenthesis', this.lineIndex + 1, this.indentWidth + this.pos + 1);
565
704
  }
705
+ const ownerIndent = this.indentWidth;
706
+ const openLine = this.lineIndex + 1;
707
+ const openCol = this.indentWidth + this.pos + 1;
566
708
  this.pos++; // skip opening (
567
709
  let depth = 1;
568
- const start = this.pos;
569
- while (this.pos < this.currentLine.length && depth > 0) {
570
- const ch = this.currentLine[this.pos];
571
- if (ch === '(') {
572
- depth++;
573
- this.pos++;
710
+ const pieces = [];
711
+ let prefix = ''; // this line's indentation relative to the owner
712
+ for (;;) {
713
+ const start = this.pos;
714
+ let end = -1; // where this line's content stops: the closing ')' or a comment
715
+ while (this.pos < this.currentLine.length) {
716
+ const ch = this.currentLine[this.pos];
717
+ if (ch === '(') {
718
+ depth++;
719
+ this.pos++;
720
+ }
721
+ else if (ch === ')') {
722
+ depth--;
723
+ if (depth === 0) {
724
+ end = this.pos;
725
+ break;
726
+ }
727
+ this.pos++;
728
+ }
729
+ else if (ch === '"' || ch === "'" || ch === '`') {
730
+ const quotePos = this.pos;
731
+ if (!this.skipStringInPlace(ch)) {
732
+ // A string has to close on its own line; letting it run
733
+ // on would throw the paren count off for every line after.
734
+ throw new LexerError('Unterminated string', this.lineIndex + 1, this.indentWidth + quotePos + 1);
735
+ }
736
+ }
737
+ else if (ch === '/' && this.currentLine[this.pos + 1] === '/') {
738
+ end = this.pos;
739
+ this.pos = this.currentLine.length;
740
+ }
741
+ else {
742
+ this.pos++;
743
+ }
574
744
  }
575
- else if (ch === ')') {
576
- depth--;
577
- if (depth === 0)
578
- break;
579
- this.pos++;
745
+ const isComment = end !== -1 && depth > 0;
746
+ const text = this.currentLine.slice(start, end === -1 ? this.currentLine.length : end).trimEnd();
747
+ if (depth === 0 && pieces.length === 0) {
748
+ // Closed on the line it opened on: the common, single-line case.
749
+ this.pos++; // skip closing )
750
+ return text.trim();
580
751
  }
581
- else if (ch === '"' || ch === "'" || ch === '`') {
582
- this.skipStringInPlace(ch);
752
+ if (pieces.length === 0) {
753
+ pieces.push(text.trimStart());
583
754
  }
584
- else {
585
- this.pos++;
755
+ else if (text !== '' || !isComment) {
756
+ // Lines holding nothing but a comment are dropped entirely.
757
+ pieces.push(text === '' ? '' : prefix + text);
586
758
  }
759
+ if (depth === 0)
760
+ break;
761
+ this.nextParamsLine(ownerIndent, openLine, openCol);
762
+ prefix = ' '.repeat(this.indentWidth - ownerIndent);
587
763
  }
588
- if (depth !== 0) {
589
- throw new LexerError('Unterminated parentheses', this.lineIndex + 1, this.indentWidth + this.pos + 1);
590
- }
591
- const content = this.currentLine.slice(start, this.pos);
592
764
  this.pos++; // skip closing )
593
- return content.trim();
765
+ const content = pieces.join('\n');
766
+ return content.trim() === '' ? '' : content;
767
+ }
768
+ // Moves onto the next non-blank line of a multi-line paren group. Lines in
769
+ // the group must be indented under its owner; the one exception is the
770
+ // line that closes it (e.g. '}: Props)'), which may sit level with the
771
+ // owner as long as it starts with a closing bracket. Anything else at or
772
+ // left of the owner means the ')' was forgotten, so stop there rather
773
+ // than swallowing the rest of the file.
774
+ nextParamsLine(ownerIndent, openLine, openCol) {
775
+ for (;;) {
776
+ if (this.lineIndex + 1 >= this.lines.length) {
777
+ throw new LexerError('Unterminated parentheses', openLine, openCol);
778
+ }
779
+ this.lineIndex++;
780
+ const rawLine = this.lines[this.lineIndex];
781
+ if (rawLine.trim().length === 0)
782
+ continue;
783
+ this.indentWidth = this.measureIndent(rawLine);
784
+ this.currentLine = rawLine.slice(this.indentWidth);
785
+ this.pos = 0;
786
+ if (this.indentWidth < ownerIndent ||
787
+ (this.indentWidth === ownerIndent && !/^[)}\]]/.test(this.currentLine))) {
788
+ throw new LexerError(`Expected ')' to close the parentheses opened at line ${openLine}; ` +
789
+ 'continuation lines must be indented under the line that opens them', this.lineIndex + 1, this.indentWidth + 1);
790
+ }
791
+ return;
792
+ }
594
793
  }
595
794
  scanString(quote) {
596
795
  const start = this.pos;
@@ -647,7 +846,10 @@ class Lexer {
647
846
  }
648
847
  throw new LexerError('Unterminated template literal', this.lineIndex + 1, this.indentWidth + this.pos + 1);
649
848
  }
650
- /** Skip over a string in-place (used within balanced paren scanning) */
849
+ /**
850
+ * Skip over a string in-place (used within balanced paren scanning).
851
+ * Returns false when the line ends before the string closes.
852
+ */
651
853
  skipStringInPlace(quote) {
652
854
  this.pos++; // skip opening quote
653
855
  while (this.pos < this.currentLine.length) {
@@ -656,7 +858,7 @@ class Lexer {
656
858
  }
657
859
  else if (this.currentLine[this.pos] === quote) {
658
860
  this.pos++;
659
- return;
861
+ return true;
660
862
  }
661
863
  else if (quote === '`' &&
662
864
  this.currentLine[this.pos] === '$' &&
@@ -666,21 +868,27 @@ class Lexer {
666
868
  this.pos += 2;
667
869
  let depth = 1;
668
870
  while (this.pos < this.currentLine.length && depth > 0) {
669
- if (this.currentLine[this.pos] === '{')
871
+ if (this.currentLine[this.pos] === '{') {
670
872
  depth++;
671
- else if (this.currentLine[this.pos] === '}')
873
+ }
874
+ else if (this.currentLine[this.pos] === '}') {
672
875
  depth--;
673
- if (depth > 0)
674
- this.pos++;
876
+ }
877
+ else if (this.currentLine[this.pos] === '"' ||
878
+ this.currentLine[this.pos] === "'" ||
879
+ this.currentLine[this.pos] === '`') {
880
+ this.skipStringInPlace(this.currentLine[this.pos]);
881
+ continue;
882
+ }
883
+ this.pos++;
675
884
  }
676
- if (this.pos < this.currentLine.length)
677
- this.pos++; // skip closing }
678
- return;
885
+ // fall through to keep scanning for the closing backtick
679
886
  }
680
887
  else {
681
888
  this.pos++;
682
889
  }
683
890
  }
891
+ return false;
684
892
  }
685
893
  // --- Utility methods ---
686
894
  scanIdentifier() {
@@ -696,17 +904,36 @@ class Lexer {
696
904
  }
697
905
  return this.currentLine.slice(start, this.pos);
698
906
  }
907
+ // Scans a '(...)' group at the cursor, if there is one, as a PARAMS token
908
+ // anchored on the '('. The group may run onto later lines, so the anchor
909
+ // is taken before scanning moves the cursor.
910
+ scanParamsIfPresent() {
911
+ if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
912
+ const line = this.lineIndex + 1;
913
+ const col = this.indentWidth + this.pos + 1;
914
+ const params = this.scanBalancedParens();
915
+ this.tokens.push({ type: 'PARAMS', value: params, line, col });
916
+ }
917
+ }
918
+ // Returns the first non-space position at or after `pos` on the current line.
919
+ skipSpacesFrom(pos) {
920
+ while (pos < this.currentLine.length && this.currentLine[pos] === ' ')
921
+ pos++;
922
+ return pos;
923
+ }
699
924
  skipSpaces() {
700
925
  while (this.pos < this.currentLine.length && this.currentLine[this.pos] === ' ') {
701
926
  this.pos++;
702
927
  }
703
928
  }
704
- emit(type, value) {
929
+ // `start` is the token's position on the current line; it defaults to the
930
+ // cursor, which for scanned tokens is already past the token itself.
931
+ emit(type, value, start = this.pos) {
705
932
  this.tokens.push({
706
933
  type,
707
934
  value,
708
935
  line: this.lineIndex + 1,
709
- col: this.indentWidth + this.pos + 1,
936
+ col: this.indentWidth + start + 1,
710
937
  });
711
938
  }
712
939
  }