@kawaijs/parser 0.1.10 → 0.1.12

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/dist/parser.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createLocation } from '@kawaijs/ast';
2
2
  import { KawaError } from './diagnostic.js';
3
+ import { unexpectedStatementHint } from './suggest.js';
3
4
  import { Lexer } from './lexer.js';
4
5
  export class Parser {
5
6
  tokens;
@@ -40,6 +41,8 @@ export class Parser {
40
41
  switch (token.type) {
41
42
  case 'CHARACTER':
42
43
  return this.parseCharacterDecl();
44
+ case 'DEFINE':
45
+ return this.parseDefineDecl();
43
46
  case 'LABEL':
44
47
  return this.parseLabelDecl();
45
48
  case 'SCENE':
@@ -52,6 +55,8 @@ export class Parser {
52
55
  return this.parseMenuStmt();
53
56
  case 'JUMP':
54
57
  return this.parseJumpStmt();
58
+ case 'CALL':
59
+ return this.parseCallStmt();
55
60
  case 'RETURN':
56
61
  return this.parseReturnStmt();
57
62
  case 'SET':
@@ -62,6 +67,32 @@ export class Parser {
62
67
  return this.parsePlayStmt();
63
68
  case 'STOP':
64
69
  return this.parseStopStmt();
70
+ case 'VFX':
71
+ return this.parseVfxStmt();
72
+ case 'CAMERA':
73
+ return this.parseCameraStmt();
74
+ case 'PAUSE':
75
+ return this.parsePauseStmt();
76
+ case 'CG':
77
+ return this.parseCgStmt();
78
+ case 'INPUT':
79
+ return this.parseInputStmt();
80
+ case 'WINDOW':
81
+ return this.parseWindowStmt();
82
+ case 'THEME':
83
+ return this.parseThemeStmt();
84
+ case 'STYLE':
85
+ return this.parseStyleStmt();
86
+ case 'HOTSPOT':
87
+ return this.parseHotspotStmt();
88
+ case 'LAYER':
89
+ return this.parseLayerStmt();
90
+ case 'ANIMATE':
91
+ return this.parseAnimateStmt();
92
+ case 'UNLOCK':
93
+ return this.parseUnlockStmt();
94
+ case 'LANG':
95
+ return this.parseLangStmt();
65
96
  case 'STRING':
66
97
  // Narration without speaker identifier
67
98
  return this.parseDialogueStmt();
@@ -74,9 +105,10 @@ export class Parser {
74
105
  }
75
106
  throw new KawaError({
76
107
  code: 'E0100',
77
- message: `Unexpected identifier '${token.value}'. Did you mean to use a command like 'show', 'scene', 'jump' or a dialogue statement?`,
108
+ message: `Unexpected statement '${token.value}'.`,
78
109
  severity: 'error',
79
- loc: token.loc
110
+ loc: token.loc,
111
+ hint: unexpectedStatementHint(token.value)
80
112
  });
81
113
  }
82
114
  default:
@@ -84,7 +116,8 @@ export class Parser {
84
116
  code: 'E0101',
85
117
  message: `Unexpected token '${token.value}' (${token.type})`,
86
118
  severity: 'error',
87
- loc: token.loc
119
+ loc: token.loc,
120
+ hint: unexpectedStatementHint(token.value)
88
121
  });
89
122
  }
90
123
  }
@@ -154,17 +187,40 @@ export class Parser {
154
187
  const startTok = this.consume('SHOW', 'Expected "show" keyword');
155
188
  const charTok = this.consume('IDENTIFIER', 'Expected character name after "show"');
156
189
  let expression;
157
- if (this.check('IDENTIFIER') && this.peek().value !== 'at' && this.peek().value !== 'with') {
190
+ if (this.check('IDENTIFIER') &&
191
+ !['at', 'with', 'z', 'layer'].includes(this.peek().value.toLowerCase())) {
158
192
  expression = this.advance().value;
159
193
  }
160
194
  let position;
161
195
  if (this.match('AT')) {
162
196
  position = this.consume('IDENTIFIER', 'Expected position identifier after "at" (e.g. left, center, right)').value;
163
197
  }
198
+ let layer;
199
+ let z;
200
+ // Optional `layer <name>` and/or `z <number>` in either order
201
+ while (!this.check('NEWLINE') && !this.check('DEDENT') && !this.check('EOF') && !this.check('WITH')) {
202
+ if (this.match('LAYER') || (this.check('IDENTIFIER') && this.peek().value.toLowerCase() === 'layer')) {
203
+ if (this.check('IDENTIFIER') && this.peek().value.toLowerCase() === 'layer') {
204
+ this.advance();
205
+ }
206
+ layer = this.check('STRING')
207
+ ? this.advance().value
208
+ : this.consume('IDENTIFIER', 'Expected layer name after "layer"').value;
209
+ continue;
210
+ }
211
+ if (this.check('IDENTIFIER') && this.peek().value.toLowerCase() === 'z') {
212
+ this.advance();
213
+ const zTok = this.consume('NUMBER', 'Expected z-index number after "z"');
214
+ z = Number(zTok.value);
215
+ continue;
216
+ }
217
+ break;
218
+ }
164
219
  let transition;
165
220
  if (this.match('WITH')) {
166
221
  transition = this.parseTransitionName();
167
222
  }
223
+ // Allow z/layer also after with (rare) — already handled above before with
168
224
  this.consumeOptionalNewline();
169
225
  return {
170
226
  type: 'ShowStmt',
@@ -172,6 +228,8 @@ export class Parser {
172
228
  expression,
173
229
  position,
174
230
  transition,
231
+ layer,
232
+ z: Number.isFinite(z) ? z : undefined,
175
233
  loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
176
234
  };
177
235
  }
@@ -209,6 +267,11 @@ export class Parser {
209
267
  speaker = spkTok.value;
210
268
  startLoc = spkTok.loc;
211
269
  }
270
+ else if (this.check('STRING') && this.peek(1).type === 'STRING') {
271
+ const spkTok = this.advance();
272
+ speaker = spkTok.value;
273
+ startLoc = spkTok.loc;
274
+ }
212
275
  const textTok = this.consume('STRING', 'Expected dialogue string');
213
276
  this.consumeOptionalNewline();
214
277
  return {
@@ -229,12 +292,17 @@ export class Parser {
229
292
  if (this.check('DEDENT') || this.isAtEnd())
230
293
  break;
231
294
  const choiceTextTok = this.consume('STRING', 'Expected choice option string (e.g. "Say hello":)');
295
+ let condition;
296
+ if (this.match('IF')) {
297
+ condition = this.readUntilColon().trim();
298
+ }
232
299
  this.consume('COLON', 'Expected ":" after choice string');
233
300
  this.consumeOptionalNewline();
234
301
  const choiceBody = this.parseBlock();
235
302
  choices.push({
236
303
  type: 'ChoiceItem',
237
304
  text: choiceTextTok.value,
305
+ condition,
238
306
  body: choiceBody,
239
307
  loc: createLocation(this.file, choiceTextTok.loc.start, this.previousLocation().end)
240
308
  });
@@ -257,6 +325,16 @@ export class Parser {
257
325
  loc: createLocation(this.file, startTok.loc.start, labelTok.loc.end)
258
326
  };
259
327
  }
328
+ parseCallStmt() {
329
+ const startTok = this.consume('CALL', 'Expected "call" keyword');
330
+ const labelTok = this.consume('IDENTIFIER', 'Expected target label name after "call"');
331
+ this.consumeOptionalNewline();
332
+ return {
333
+ type: 'CallStmt',
334
+ targetLabel: labelTok.value,
335
+ loc: createLocation(this.file, startTok.loc.start, labelTok.loc.end)
336
+ };
337
+ }
260
338
  parseReturnStmt() {
261
339
  const startTok = this.consume('RETURN', 'Expected "return" keyword');
262
340
  this.consumeOptionalNewline();
@@ -287,8 +365,10 @@ export class Parser {
287
365
  });
288
366
  }
289
367
  let value;
368
+ let isVariable = false;
290
369
  if (this.check('STRING')) {
291
370
  value = this.advance().value;
371
+ isVariable = false;
292
372
  }
293
373
  else if (this.check('NUMBER')) {
294
374
  value = Number(this.advance().value);
@@ -298,6 +378,7 @@ export class Parser {
298
378
  }
299
379
  else if (this.check('IDENTIFIER')) {
300
380
  value = this.advance().value;
381
+ isVariable = true;
301
382
  }
302
383
  else {
303
384
  throw new KawaError({
@@ -313,6 +394,7 @@ export class Parser {
313
394
  variable: varTok.value,
314
395
  operator,
315
396
  value,
397
+ isVariable,
316
398
  loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
317
399
  };
318
400
  }
@@ -368,7 +450,15 @@ export class Parser {
368
450
  loc: channelTok.loc
369
451
  });
370
452
  }
371
- const trackTok = this.consume('IDENTIFIER', 'Expected audio track name');
453
+ if (!this.check('IDENTIFIER') && !this.check('STRING') && !this.checkSoftIdentifier()) {
454
+ throw new KawaError({
455
+ code: 'E0104',
456
+ message: 'Expected audio track name (identifier or string literal) after audio channel',
457
+ severity: 'error',
458
+ loc: this.currentLocation()
459
+ });
460
+ }
461
+ const trackTok = this.advance();
372
462
  let fade;
373
463
  let loop = channelTok.type === 'MUSIC';
374
464
  while (this.check('FADEIN') || this.check('LOOP')) {
@@ -412,6 +502,363 @@ export class Parser {
412
502
  loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
413
503
  };
414
504
  }
505
+ parseVfxStmt() {
506
+ const startTok = this.consume('VFX', 'Expected "vfx" keyword');
507
+ const effectTok = this.advance();
508
+ const effectName = effectTok.value.toLowerCase();
509
+ const validEffects = ['rain', 'snow', 'sakura', 'fog', 'tint', 'stop'];
510
+ if (!validEffects.includes(effectName)) {
511
+ throw new KawaError({
512
+ code: 'E0107',
513
+ message: `Invalid VFX effect '${effectTok.value}'. Expected one of: ${validEffects.join(', ')}`,
514
+ severity: 'error',
515
+ loc: effectTok.loc
516
+ });
517
+ }
518
+ let intensity;
519
+ let color;
520
+ if (effectName === 'tint') {
521
+ if (this.check('COLOR')) {
522
+ color = this.advance().value;
523
+ }
524
+ else if (this.check('STRING')) {
525
+ color = this.advance().value;
526
+ }
527
+ else if (this.check('IDENTIFIER')) {
528
+ color = this.advance().value;
529
+ }
530
+ }
531
+ else if (effectName !== 'stop') {
532
+ if (this.check('NUMBER')) {
533
+ intensity = Number(this.advance().value);
534
+ }
535
+ else if (this.check('IDENTIFIER')) {
536
+ intensity = this.advance().value;
537
+ }
538
+ }
539
+ this.consumeOptionalNewline();
540
+ return {
541
+ type: 'VfxStmt',
542
+ effect: effectName,
543
+ intensity,
544
+ color,
545
+ loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
546
+ };
547
+ }
548
+ parseCameraStmt() {
549
+ const startTok = this.consume('CAMERA', 'Expected "camera" keyword');
550
+ const actionTok = this.advance();
551
+ const actionName = actionTok.value.toLowerCase();
552
+ const validActions = ['shake', 'vpunch', 'hpunch', 'flash'];
553
+ if (!validActions.includes(actionName)) {
554
+ throw new KawaError({
555
+ code: 'E0108',
556
+ message: `Invalid camera action '${actionTok.value}'. Expected one of: ${validActions.join(', ')}`,
557
+ severity: 'error',
558
+ loc: actionTok.loc
559
+ });
560
+ }
561
+ let duration;
562
+ if (this.check('NUMBER')) {
563
+ duration = Number(this.advance().value);
564
+ }
565
+ this.consumeOptionalNewline();
566
+ return {
567
+ type: 'CameraStmt',
568
+ action: actionName,
569
+ duration,
570
+ loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
571
+ };
572
+ }
573
+ parsePauseStmt() {
574
+ const startTok = this.consume('PAUSE', 'Expected "pause" keyword');
575
+ let duration;
576
+ if (this.check('NUMBER')) {
577
+ duration = Number(this.advance().value);
578
+ }
579
+ this.consumeOptionalNewline();
580
+ return {
581
+ type: 'PauseStmt',
582
+ duration,
583
+ loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
584
+ };
585
+ }
586
+ parseCgStmt() {
587
+ const startTok = this.consume('CG', 'Expected "cg" keyword');
588
+ const imageTok = this.consume('STRING', 'Expected image filename or path in quotes after "cg"');
589
+ let unlockId;
590
+ if (this.match('AS')) {
591
+ if (this.check('STRING')) {
592
+ unlockId = this.advance().value;
593
+ }
594
+ else {
595
+ unlockId = this.consume('IDENTIFIER', 'Expected unlock identifier after "as"').value;
596
+ }
597
+ }
598
+ this.consumeOptionalNewline();
599
+ return {
600
+ type: 'CgStmt',
601
+ image: imageTok.value,
602
+ unlockId,
603
+ loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
604
+ };
605
+ }
606
+ parseDefineDecl() {
607
+ const startTok = this.consume('DEFINE', 'Expected "define" keyword');
608
+ const nameParts = [];
609
+ while (this.checkSoftIdentifier()) {
610
+ nameParts.push(this.advance().value);
611
+ if (this.check('EQUALS') || this.check('STRING'))
612
+ break;
613
+ }
614
+ if (nameParts.length === 0) {
615
+ throw new KawaError({
616
+ code: 'E0110',
617
+ message: 'Expected alias name after "define"',
618
+ severity: 'error',
619
+ loc: this.currentLocation()
620
+ });
621
+ }
622
+ this.consume('EQUALS', 'Expected "=" after define name');
623
+ const valueTok = this.consume('STRING', 'Expected string value after "define ... ="');
624
+ this.consumeOptionalNewline();
625
+ return {
626
+ type: 'DefineDecl',
627
+ name: nameParts.join(' '),
628
+ value: valueTok.value,
629
+ loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
630
+ };
631
+ }
632
+ parseInputStmt() {
633
+ const startTok = this.consume('INPUT', 'Expected "input" keyword');
634
+ const varTok = this.consume('IDENTIFIER', 'Expected variable name after "input"');
635
+ let prompt = 'Enter text';
636
+ if (this.check('STRING')) {
637
+ prompt = this.advance().value;
638
+ }
639
+ this.consumeOptionalNewline();
640
+ return {
641
+ type: 'InputStmt',
642
+ variable: varTok.value,
643
+ prompt,
644
+ loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
645
+ };
646
+ }
647
+ parseWindowStmt() {
648
+ const startTok = this.consume('WINDOW', 'Expected "window" keyword');
649
+ let actionRaw;
650
+ if (this.check('SHOW') || this.check('HIDE') || this.check('IDENTIFIER')) {
651
+ actionRaw = this.advance().value.toLowerCase();
652
+ }
653
+ else {
654
+ throw new KawaError({
655
+ code: 'E0111',
656
+ message: 'Expected "show" or "hide" after "window"',
657
+ severity: 'error',
658
+ loc: this.currentLocation()
659
+ });
660
+ }
661
+ if (actionRaw !== 'show' && actionRaw !== 'hide') {
662
+ throw new KawaError({
663
+ code: 'E0111',
664
+ message: `Invalid window action '${actionRaw}'. Expected "show" or "hide".`,
665
+ severity: 'error',
666
+ loc: startTok.loc
667
+ });
668
+ }
669
+ this.consumeOptionalNewline();
670
+ return {
671
+ type: 'WindowStmt',
672
+ action: actionRaw,
673
+ loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
674
+ };
675
+ }
676
+ parseThemeStmt() {
677
+ const startTok = this.consume('THEME', 'Expected "theme" keyword');
678
+ let name;
679
+ if (this.check('STRING') || this.checkSoftIdentifier()) {
680
+ name = this.advance().value;
681
+ }
682
+ else {
683
+ throw new KawaError({
684
+ code: 'E0112',
685
+ message: 'Expected theme name after "theme"',
686
+ severity: 'error',
687
+ loc: this.currentLocation(),
688
+ hint: 'Example: theme "noir" or theme sakura'
689
+ });
690
+ }
691
+ this.consumeOptionalNewline();
692
+ return {
693
+ type: 'ThemeStmt',
694
+ name,
695
+ loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
696
+ };
697
+ }
698
+ parseStyleStmt() {
699
+ const startTok = this.consume('STYLE', 'Expected "style" keyword');
700
+ if (!this.check('STRING') && !this.checkSoftIdentifier()) {
701
+ throw new KawaError({
702
+ code: 'E0113',
703
+ message: 'Expected style target after "style"',
704
+ severity: 'error',
705
+ loc: this.currentLocation(),
706
+ hint: 'Example: style dialogue glass\n Targets: dialogue, stage, root, choices'
707
+ });
708
+ }
709
+ const targetTok = this.advance();
710
+ if (!this.check('STRING') && !this.checkSoftIdentifier()) {
711
+ throw new KawaError({
712
+ code: 'E0113',
713
+ message: 'Expected style name after target',
714
+ severity: 'error',
715
+ loc: this.currentLocation(),
716
+ hint: 'Example: style dialogue glass'
717
+ });
718
+ }
719
+ const nameTok = this.advance();
720
+ this.consumeOptionalNewline();
721
+ return {
722
+ type: 'StyleStmt',
723
+ target: targetTok.value.toLowerCase(),
724
+ name: nameTok.value,
725
+ loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
726
+ };
727
+ }
728
+ parseHotspotStmt() {
729
+ const startTok = this.consume('HOTSPOT', 'Expected "hotspot" keyword');
730
+ let id;
731
+ if (this.check('STRING') || this.checkSoftIdentifier()) {
732
+ id = this.advance().value;
733
+ }
734
+ else {
735
+ throw new KawaError({
736
+ code: 'E0114',
737
+ message: 'Expected hotspot id after "hotspot"',
738
+ severity: 'error',
739
+ loc: this.currentLocation(),
740
+ hint: 'Example: hotspot door 40 50 18 12 jump courtyard\n Coordinates are percent of the stage (0–100) or fractions (0–1).'
741
+ });
742
+ }
743
+ const readCoord = (label) => {
744
+ if (!this.check('NUMBER')) {
745
+ throw new KawaError({
746
+ code: 'E0114',
747
+ message: `Expected ${label} coordinate after hotspot id`,
748
+ severity: 'error',
749
+ loc: this.currentLocation(),
750
+ hint: 'Example: hotspot door 40 50 18 12 jump courtyard'
751
+ });
752
+ }
753
+ return Number(this.advance().value);
754
+ };
755
+ const x = readCoord('x');
756
+ const y = readCoord('y');
757
+ const w = readCoord('w');
758
+ const h = readCoord('h');
759
+ if (!this.check('JUMP')) {
760
+ throw new KawaError({
761
+ code: 'E0114',
762
+ message: 'Expected "jump" after hotspot rectangle',
763
+ severity: 'error',
764
+ loc: this.currentLocation(),
765
+ hint: 'Example: hotspot door 40 50 18 12 jump courtyard'
766
+ });
767
+ }
768
+ this.advance();
769
+ const target = this.consume('IDENTIFIER', 'Expected target label after hotspot jump').value;
770
+ this.consumeOptionalNewline();
771
+ return {
772
+ type: 'HotspotStmt',
773
+ id,
774
+ x,
775
+ y,
776
+ w,
777
+ h,
778
+ targetLabel: target,
779
+ loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
780
+ };
781
+ }
782
+ parseLayerStmt() {
783
+ const startTok = this.consume('LAYER', 'Expected "layer" keyword');
784
+ const name = this.check('STRING')
785
+ ? this.advance().value
786
+ : this.consume('IDENTIFIER', 'Expected layer name (e.g. master, overlay)').value;
787
+ this.consumeOptionalNewline();
788
+ return {
789
+ type: 'LayerStmt',
790
+ name,
791
+ loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
792
+ };
793
+ }
794
+ parseAnimateStmt() {
795
+ const startTok = this.consume('ANIMATE', 'Expected "animate" keyword');
796
+ const charTok = this.consume('IDENTIFIER', 'Expected character name after "animate"');
797
+ this.consume('WITH', 'Expected "with" after character in animate statement');
798
+ let animation;
799
+ let durationMs;
800
+ if (this.check('STRING')) {
801
+ const raw = this.advance().value.trim();
802
+ // "slide-in 400ms" | "slide-in 400" | "slide-in"
803
+ const match = raw.match(/^([a-zA-Z0-9_-]+)\s*(\d+)\s*(ms)?$/i) || raw.match(/^([a-zA-Z0-9_-]+)$/);
804
+ if (match) {
805
+ animation = match[1];
806
+ if (match[2])
807
+ durationMs = Number(match[2]);
808
+ }
809
+ else {
810
+ animation = raw.replace(/\s+/g, '-');
811
+ }
812
+ }
813
+ else {
814
+ animation = this.consume('IDENTIFIER', 'Expected animation name after "with"').value;
815
+ if (this.check('NUMBER')) {
816
+ durationMs = Number(this.advance().value);
817
+ }
818
+ }
819
+ this.consumeOptionalNewline();
820
+ return {
821
+ type: 'AnimateStmt',
822
+ character: charTok.value,
823
+ animation: animation.toLowerCase(),
824
+ durationMs: Number.isFinite(durationMs) ? durationMs : undefined,
825
+ loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
826
+ };
827
+ }
828
+ parseUnlockStmt() {
829
+ const startTok = this.consume('UNLOCK', 'Expected "unlock" keyword');
830
+ const id = this.check('STRING')
831
+ ? this.advance().value
832
+ : this.consume('IDENTIFIER', 'Expected achievement id after "unlock"').value;
833
+ let title;
834
+ let description;
835
+ if (this.check('STRING')) {
836
+ title = this.advance().value;
837
+ if (this.check('STRING')) {
838
+ description = this.advance().value;
839
+ }
840
+ }
841
+ this.consumeOptionalNewline();
842
+ return {
843
+ type: 'UnlockStmt',
844
+ id,
845
+ title,
846
+ description,
847
+ loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
848
+ };
849
+ }
850
+ parseLangStmt() {
851
+ const startTok = this.consume('LANG', 'Expected "lang" keyword');
852
+ const code = this.check('STRING')
853
+ ? this.advance().value
854
+ : this.consume('IDENTIFIER', 'Expected language code after "lang" (e.g. it, en)').value;
855
+ this.consumeOptionalNewline();
856
+ return {
857
+ type: 'LangStmt',
858
+ code: code.toLowerCase(),
859
+ loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
860
+ };
861
+ }
415
862
  parseBlock() {
416
863
  this.consume('INDENT', 'Expected indented block');
417
864
  const statements = [];
@@ -431,7 +878,13 @@ export class Parser {
431
878
  readUntilColon() {
432
879
  const parts = [];
433
880
  while (!this.check('COLON') && !this.check('NEWLINE') && !this.isAtEnd()) {
434
- parts.push(this.advance().value);
881
+ const tok = this.advance();
882
+ if (tok.type === 'STRING') {
883
+ parts.push(JSON.stringify(tok.value));
884
+ }
885
+ else {
886
+ parts.push(tok.value);
887
+ }
435
888
  }
436
889
  return parts.join(' ');
437
890
  }
@@ -445,6 +898,40 @@ export class Parser {
445
898
  this.advance();
446
899
  }
447
900
  }
901
+ checkSoftIdentifier() {
902
+ if (this.isAtEnd())
903
+ return false;
904
+ const t = this.peek().type;
905
+ if (t === 'IDENTIFIER')
906
+ return true;
907
+ // Keywords may appear as names (e.g. `define music theme`, `hotspot window`, `play music theme`)
908
+ switch (t) {
909
+ case 'NEWLINE':
910
+ case 'INDENT':
911
+ case 'DEDENT':
912
+ case 'EOF':
913
+ case 'COLON':
914
+ case 'EQUALS':
915
+ case 'PLUS_EQUALS':
916
+ case 'MINUS_EQUALS':
917
+ case 'DOUBLE_EQUALS':
918
+ case 'NOT_EQUALS':
919
+ case 'GREATER_EQUALS':
920
+ case 'LESS_EQUALS':
921
+ case 'GREATER':
922
+ case 'LESS':
923
+ case 'PLUS':
924
+ case 'MINUS':
925
+ case 'COMMA':
926
+ case 'STRING':
927
+ case 'NUMBER':
928
+ case 'BOOLEAN':
929
+ case 'COLOR':
930
+ return false;
931
+ default:
932
+ return true;
933
+ }
934
+ }
448
935
  match(type) {
449
936
  if (this.check(type)) {
450
937
  this.advance();