septima-lang 0.1.0 → 0.2.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.
package/src/parser.ts ADDED
@@ -0,0 +1,698 @@
1
+ import {
2
+ ArrayLiteralPart,
3
+ AstNode,
4
+ FormalArg,
5
+ Ident,
6
+ Import,
7
+ Let,
8
+ Literal,
9
+ ObjectLiteralPart,
10
+ span,
11
+ TemplatePart,
12
+ Unit,
13
+ } from './ast-node'
14
+ import { Scanner, Token } from './scanner'
15
+ import { switchOn } from './switch-on'
16
+
17
+ export class Parser {
18
+ constructor(private readonly scanner: Scanner) {}
19
+
20
+ private get unitId() {
21
+ return this.scanner.sourceCode.pathFromSourceRoot
22
+ }
23
+
24
+ parse(): Unit {
25
+ const ret = this.unit()
26
+ if (!this.scanner.eof()) {
27
+ throw new Error(`Loitering input ${this.scanner.sourceRef}`)
28
+ }
29
+ return ret
30
+ }
31
+
32
+ unit(): Unit {
33
+ const imports = this.imports()
34
+ const expression = this.expression('TOP_LEVEL')
35
+ return { tag: 'unit', imports, expression, unitId: this.scanner.sourceCode.pathFromSourceRoot }
36
+ }
37
+
38
+ imports(): Import[] {
39
+ const ret: Import[] = []
40
+ while (true) {
41
+ const start = this.scanner.consumeIf('import')
42
+ if (!start) {
43
+ return ret
44
+ }
45
+
46
+ this.scanner.consume('*')
47
+ this.scanner.consume('as')
48
+ const ident = this.identifier()
49
+ this.scanner.consume('from')
50
+ const pathToImportFrom = this.maybePrimitiveLiteral()
51
+ if (pathToImportFrom === undefined) {
52
+ throw new Error(`Expected a literal ${this.scanner.sourceRef}`)
53
+ }
54
+
55
+ const notString = () => {
56
+ throw new Error(`Expected a string literal ${this.scanner.sourceCode.sourceRef(span(pathToImportFrom))}`)
57
+ }
58
+ switchOn(pathToImportFrom.type, {
59
+ bool: notString,
60
+ num: notString,
61
+ undef: notString,
62
+ str: () => {},
63
+ })
64
+ ret.push({ start, ident, pathToImportFrom: pathToImportFrom.t, unitId: this.unitId })
65
+
66
+ this.scanner.consumeIf(';')
67
+ }
68
+ }
69
+
70
+ definitions(kind: 'TOP_LEVEL' | 'NESTED'): Let[] {
71
+ const ret: Let[] = []
72
+ while (true) {
73
+ if (this.scanner.consumeIf(';')) {
74
+ continue
75
+ }
76
+ if (kind === 'NESTED') {
77
+ if (this.scanner.headMatches('export ')) {
78
+ throw new Error(`non-top-level definition cannot be exported ${this.scanner.sourceRef}`)
79
+ }
80
+ }
81
+ let start = this.scanner.consumeIf('let ') ?? this.scanner.consumeIf('const ')
82
+ let isExported = false
83
+ if (!start && kind === 'TOP_LEVEL') {
84
+ start = this.scanner.consumeIf('export let ') ?? this.scanner.consumeIf('export const ')
85
+ isExported = true
86
+ }
87
+ if (!start) {
88
+ return ret
89
+ }
90
+ const ident = this.identifier()
91
+ this.scanner.consume('=')
92
+ const value = this.lambda()
93
+ ret.push({ start, ident, value, isExported })
94
+
95
+ if (this.scanner.headMatches(';')) {
96
+ continue
97
+ }
98
+ if (this.scanner.headMatches('let ') || this.scanner.headMatches('const ')) {
99
+ continue
100
+ }
101
+
102
+ if (this.scanner.headMatches('export ')) {
103
+ continue
104
+ }
105
+ return ret
106
+ }
107
+ }
108
+
109
+ expression(kind: 'TOP_LEVEL' | 'NESTED' = 'NESTED'): AstNode {
110
+ const definitions = this.definitions(kind)
111
+ if (kind === 'TOP_LEVEL' && this.scanner.eof()) {
112
+ return { tag: 'topLevelExpression', definitions, unitId: this.unitId }
113
+ }
114
+ const throwToken = this.scanner.consumeIf('throw')
115
+ this.scanner.consumeIf('return')
116
+ const computation = this.lambda()
117
+
118
+ if (definitions.length === 0 && !throwToken) {
119
+ return computation
120
+ }
121
+
122
+ return { tag: 'topLevelExpression', definitions, throwToken, computation, unitId: this.unitId }
123
+ }
124
+
125
+ formalArg(): FormalArg {
126
+ const arg = this.identifier()
127
+ let defaultValue: AstNode | undefined = undefined
128
+
129
+ if (!this.scanner.headMatches('=>') && this.scanner.consumeIf('=')) {
130
+ defaultValue = this.expression()
131
+ }
132
+ return { tag: 'formalArg', ident: arg, defaultValue, unitId: this.unitId }
133
+ }
134
+
135
+ lambda(): AstNode {
136
+ const start = this.scanner.consumeIf('fun')
137
+ if (!start) {
138
+ return this.arrowFunction()
139
+ }
140
+
141
+ this.scanner.consume('(')
142
+ const args: FormalArg[] = []
143
+
144
+ if (this.scanner.consumeIf(')')) {
145
+ // no formal args
146
+ } else {
147
+ while (true) {
148
+ const arg = this.formalArg()
149
+ args.push(arg)
150
+ if (this.scanner.consumeIf(')')) {
151
+ break
152
+ }
153
+
154
+ this.scanner.consume(',')
155
+ }
156
+ }
157
+
158
+ const body = this.expression()
159
+ return { tag: 'lambda', start, formalArgs: args, body, unitId: this.unitId }
160
+ }
161
+
162
+ arrowFunction(): AstNode {
163
+ const unitId = this.unitId
164
+
165
+ if (this.scanner.headMatches('(', ')', '=>')) {
166
+ const start = this.scanner.consume('(')
167
+ this.scanner.consume(')')
168
+ this.scanner.consume('=>')
169
+ const body = this.lambdaBody()
170
+ return { tag: 'lambda', start, formalArgs: [], body, unitId }
171
+ }
172
+ if (this.scanner.headMatches(IDENT_PATTERN, '=>')) {
173
+ const formal = this.formalArg()
174
+ this.scanner.consume('=>')
175
+ const body = this.lambdaBody()
176
+ return { tag: 'lambda', start: formal.ident.t, formalArgs: [formal], body, unitId }
177
+ }
178
+ if (this.scanner.headMatches('(', IDENT_PATTERN, ')', '=>')) {
179
+ const start = this.scanner.consume('(')
180
+ const formal = this.formalArg()
181
+ this.scanner.consume(')')
182
+ this.scanner.consume('=>')
183
+ const body = this.lambdaBody()
184
+ return { tag: 'lambda', start, formalArgs: [formal], body, unitId }
185
+ }
186
+ if (this.scanner.headMatches('(', IDENT_PATTERN, { either: [',', '='], noneOf: ['=='] })) {
187
+ const start = this.scanner.consume('(')
188
+ const formalArgs: FormalArg[] = []
189
+ let defaultSeen = false
190
+ while (true) {
191
+ const pos = this.scanner.sourceRef
192
+ const formal = this.formalArg()
193
+ if (defaultSeen && !formal.defaultValue) {
194
+ throw new Error(`A required parameter cannot follow an optional parameter: ${pos}`)
195
+ }
196
+
197
+ defaultSeen = defaultSeen || Boolean(formal.defaultValue)
198
+ formalArgs.push(formal)
199
+
200
+ if (this.scanner.consumeIf(')')) {
201
+ break
202
+ }
203
+
204
+ this.scanner.consume(',')
205
+ if (this.scanner.consumeIf(')')) {
206
+ break
207
+ }
208
+ }
209
+
210
+ this.scanner.consume('=>')
211
+ const body = this.lambdaBody()
212
+ return { tag: 'lambda', start, formalArgs, body, unitId }
213
+ }
214
+
215
+ return this.ifExpression()
216
+ }
217
+
218
+ private lambdaBody() {
219
+ if (this.scanner.consumeIf('{')) {
220
+ const ret = this.expression()
221
+ this.scanner.consume('}')
222
+ return ret
223
+ }
224
+
225
+ return this.expression()
226
+ }
227
+
228
+ ifExpression(): AstNode {
229
+ if (!this.scanner.consumeIf('if')) {
230
+ return this.ternary()
231
+ }
232
+
233
+ this.scanner.consume('(')
234
+ const condition = this.expression()
235
+ this.scanner.consume(')')
236
+
237
+ const positive = this.expression()
238
+
239
+ this.scanner.consume('else')
240
+
241
+ const negative = this.expression()
242
+
243
+ return { tag: 'if', condition, positive, negative, unitId: this.unitId }
244
+ }
245
+
246
+ ternary(): AstNode {
247
+ const condition = this.undefinedCoallesing()
248
+ if (this.scanner.headMatches('??')) {
249
+ return condition
250
+ }
251
+
252
+ if (!this.scanner.consumeIf('?')) {
253
+ return condition
254
+ }
255
+
256
+ const positive = this.expression()
257
+ this.scanner.consume(':')
258
+ const negative = this.expression()
259
+
260
+ return { tag: 'ternary', condition, positive, negative, unitId: this.unitId }
261
+ }
262
+
263
+ undefinedCoallesing(): AstNode {
264
+ const lhs = this.or()
265
+ if (this.scanner.consumeIf('??')) {
266
+ return { tag: 'binaryOperator', operator: '??', lhs, rhs: this.undefinedCoallesing(), unitId: this.unitId }
267
+ }
268
+ return lhs
269
+ }
270
+
271
+ or(): AstNode {
272
+ const lhs = this.and()
273
+ if (this.scanner.consumeIf('||')) {
274
+ return { tag: 'binaryOperator', operator: '||', lhs, rhs: this.or(), unitId: this.unitId }
275
+ }
276
+ return lhs
277
+ }
278
+
279
+ and(): AstNode {
280
+ const lhs = this.equality()
281
+ if (this.scanner.consumeIf('&&')) {
282
+ return { tag: 'binaryOperator', operator: '&&', lhs, rhs: this.and(), unitId: this.unitId }
283
+ }
284
+ return lhs
285
+ }
286
+
287
+ equality(): AstNode {
288
+ const lhs = this.comparison()
289
+ if (this.scanner.consumeIf('==')) {
290
+ return { tag: 'binaryOperator', operator: '==', lhs, rhs: this.equality(), unitId: this.unitId }
291
+ }
292
+ if (this.scanner.consumeIf('!=')) {
293
+ return { tag: 'binaryOperator', operator: '!=', lhs, rhs: this.equality(), unitId: this.unitId }
294
+ }
295
+ return lhs
296
+ }
297
+
298
+ comparison(): AstNode {
299
+ const lhs = this.addition()
300
+ if (this.scanner.consumeIf('>=')) {
301
+ return { tag: 'binaryOperator', operator: '>=', lhs, rhs: this.comparison(), unitId: this.unitId }
302
+ }
303
+ if (this.scanner.consumeIf('<=')) {
304
+ return { tag: 'binaryOperator', operator: '<=', lhs, rhs: this.comparison(), unitId: this.unitId }
305
+ }
306
+ if (this.scanner.consumeIf('>')) {
307
+ return { tag: 'binaryOperator', operator: '>', lhs, rhs: this.comparison(), unitId: this.unitId }
308
+ }
309
+ if (this.scanner.consumeIf('<')) {
310
+ return { tag: 'binaryOperator', operator: '<', lhs, rhs: this.comparison(), unitId: this.unitId }
311
+ }
312
+ return lhs
313
+ }
314
+
315
+ addition(): AstNode {
316
+ const lhs = this.multiplication()
317
+ if (this.scanner.consumeIf('+')) {
318
+ return { tag: 'binaryOperator', operator: '+', lhs, rhs: this.addition(), unitId: this.unitId }
319
+ }
320
+ if (this.scanner.consumeIf('-')) {
321
+ return { tag: 'binaryOperator', operator: '-', lhs, rhs: this.addition(), unitId: this.unitId }
322
+ }
323
+ return lhs
324
+ }
325
+
326
+ multiplication(): AstNode {
327
+ const lhs = this.power()
328
+ if (this.scanner.consumeIf('*')) {
329
+ return { tag: 'binaryOperator', operator: '*', lhs, rhs: this.multiplication(), unitId: this.unitId }
330
+ }
331
+ if (this.scanner.consumeIf('/')) {
332
+ return { tag: 'binaryOperator', operator: '/', lhs, rhs: this.multiplication(), unitId: this.unitId }
333
+ }
334
+ if (this.scanner.consumeIf('%')) {
335
+ return { tag: 'binaryOperator', operator: '%', lhs, rhs: this.multiplication(), unitId: this.unitId }
336
+ }
337
+ return lhs
338
+ }
339
+
340
+ power(): AstNode {
341
+ const lhs = this.unary()
342
+ if (this.scanner.consumeIf('**')) {
343
+ return { tag: 'binaryOperator', operator: '**', lhs, rhs: this.power(), unitId: this.unitId }
344
+ }
345
+ return lhs
346
+ }
347
+
348
+ unary(): AstNode {
349
+ let operatorToken = this.scanner.consumeIf('!')
350
+ if (operatorToken) {
351
+ return { tag: 'unaryOperator', operand: this.unary(), operator: '!', operatorToken, unitId: this.unitId }
352
+ }
353
+ operatorToken = this.scanner.consumeIf('+')
354
+ if (operatorToken) {
355
+ return { tag: 'unaryOperator', operand: this.unary(), operator: '+', operatorToken, unitId: this.unitId }
356
+ }
357
+ operatorToken = this.scanner.consumeIf('-')
358
+ if (operatorToken) {
359
+ return { tag: 'unaryOperator', operand: this.unary(), operator: '-', operatorToken, unitId: this.unitId }
360
+ }
361
+
362
+ return this.call()
363
+ }
364
+
365
+ call(): AstNode {
366
+ const callee = this.memberAccess()
367
+
368
+ if (!this.scanner.consumeIf('(')) {
369
+ return callee
370
+ }
371
+
372
+ const { actualArgs, end } = this.actualArgList()
373
+ return { tag: 'functionCall', actualArgs, callee, end, unitId: this.unitId }
374
+ }
375
+
376
+ private actualArgList() {
377
+ const actualArgs: AstNode[] = []
378
+ const endEmpty = this.scanner.consumeIf(')')
379
+ if (endEmpty) {
380
+ // no actual args
381
+ return { actualArgs, end: endEmpty }
382
+ }
383
+
384
+ while (true) {
385
+ const arg = this.expression()
386
+ actualArgs.push(arg)
387
+ let end = this.scanner.consumeIf(')')
388
+ if (end) {
389
+ return { actualArgs, end }
390
+ }
391
+ this.scanner.consume(',')
392
+ end = this.scanner.consumeIf(')')
393
+ if (end) {
394
+ return { actualArgs, end }
395
+ }
396
+ }
397
+ }
398
+
399
+ memberAccess(): AstNode {
400
+ let ret = this.parenthesized()
401
+
402
+ while (true) {
403
+ if (this.scanner.consumeIf('.')) {
404
+ ret = { tag: 'dot', receiver: ret, ident: this.identifier(), unitId: this.unitId }
405
+ continue
406
+ }
407
+
408
+ if (this.scanner.consumeIf('[')) {
409
+ ret = { tag: 'indexAccess', receiver: ret, index: this.expression(), unitId: this.unitId }
410
+ this.scanner.consume(']')
411
+ continue
412
+ }
413
+
414
+ if (this.scanner.consumeIf('(')) {
415
+ const { actualArgs, end } = this.actualArgList()
416
+ ret = { tag: 'functionCall', actualArgs, callee: ret, end, unitId: this.unitId }
417
+ continue
418
+ }
419
+
420
+ return ret
421
+ }
422
+ }
423
+
424
+ parenthesized(): AstNode {
425
+ if (this.scanner.consumeIf('(')) {
426
+ const ret = this.expression()
427
+ this.scanner.consume(')')
428
+ return ret
429
+ }
430
+
431
+ return this.literalOrIdent()
432
+ }
433
+
434
+ literalOrIdent(): AstNode {
435
+ const ret = this.maybeLiteral() ?? this.maybeIdentifier()
436
+ if (!ret) {
437
+ throw new Error(`Unparsable input ${this.scanner.sourceRef}`)
438
+ }
439
+ return ret
440
+ }
441
+
442
+ maybeLiteral(): AstNode | undefined {
443
+ return this.maybePrimitiveLiteral() ?? this.maybeTemplateLiteral() ?? this.maybeCompositeLiteral()
444
+ }
445
+
446
+ maybePrimitiveLiteral(): Literal | undefined {
447
+ let t = this.scanner.consumeIf('undefined')
448
+ if (t) {
449
+ return { tag: 'literal', type: 'undef', t, unitId: this.unitId }
450
+ }
451
+ t = this.scanner.consumeIf('true')
452
+ if (t) {
453
+ return { tag: 'literal', type: 'bool', t, unitId: this.unitId }
454
+ }
455
+ t = this.scanner.consumeIf('false')
456
+ if (t) {
457
+ return { tag: 'literal', type: 'bool', t, unitId: this.unitId }
458
+ }
459
+
460
+ t = this.scanner.consumeIf(/([0-9]*[.])?[0-9]+/)
461
+ if (t) {
462
+ return { tag: 'literal', type: 'num', t, unitId: this.unitId }
463
+ }
464
+
465
+ const stringLiteral = this.maybeStringLiteral()
466
+ if (stringLiteral) {
467
+ return stringLiteral
468
+ }
469
+
470
+ return undefined
471
+ }
472
+
473
+ maybeStringLiteral(): Literal | undefined {
474
+ // double-quotes-enclosd string
475
+ if (this.scanner.consumeIf(`"`, false)) {
476
+ const t = this.scanner.consume(/[^"]*/)
477
+ this.scanner.consume(`"`)
478
+ return { tag: 'literal', type: 'str', t, unitId: this.unitId }
479
+ }
480
+
481
+ // single-quotes-enclosd string
482
+ if (this.scanner.consumeIf(`'`, false)) {
483
+ const t = this.scanner.consume(/[^']*/)
484
+ this.scanner.consume(`'`)
485
+ return { tag: 'literal', type: 'str', t, unitId: this.unitId }
486
+ }
487
+
488
+ return undefined
489
+ }
490
+
491
+ maybeTemplateLiteral(): AstNode | undefined {
492
+ const start = this.scanner.consumeIf('`', false)
493
+ if (!start) {
494
+ return undefined
495
+ }
496
+
497
+ const parts: TemplatePart[] = []
498
+ let currentString = ''
499
+
500
+ while (true) {
501
+ if (this.scanner.eof()) {
502
+ throw new Error(`Unterminated template literal ${this.scanner.sourceRef}`)
503
+ }
504
+
505
+ // Consume any characters that are not `, $, or \
506
+ const text = this.scanner.consumeIf(/[^`$\\]+/, false)
507
+ if (text) {
508
+ currentString += text.text
509
+ continue
510
+ }
511
+
512
+ if (this.scanner.consumeIf('\\', false)) {
513
+ // Escape sequence
514
+ if (this.scanner.consumeIf('$', false)) {
515
+ currentString += '$'
516
+ } else if (this.scanner.consumeIf('\\', false)) {
517
+ currentString += '\\'
518
+ } else if (this.scanner.consumeIf('`', false)) {
519
+ currentString += '`'
520
+ } else if (this.scanner.consumeIf('n', false)) {
521
+ currentString += '\n'
522
+ } else if (this.scanner.consumeIf('t', false)) {
523
+ currentString += '\t'
524
+ } else if (this.scanner.consumeIf('r', false)) {
525
+ currentString += '\r'
526
+ } else {
527
+ // Unknown escape - keep both backslash and next char
528
+ const nextChar = this.scanner.consumeIf(/./, false)
529
+ currentString += '\\' + (nextChar?.text ?? '')
530
+ }
531
+ continue
532
+ }
533
+
534
+ if (this.scanner.consumeIf('${', true)) {
535
+ // Start of interpolation
536
+ if (currentString.length > 0) {
537
+ parts.push({ tag: 'string', value: currentString })
538
+ currentString = ''
539
+ }
540
+
541
+ // Parse the expression inside ${} - eat whitespace before expression
542
+ const expr = this.expression()
543
+ // Don't eat whitespace after } to preserve it in the template
544
+ this.scanner.consume('}', false)
545
+
546
+ parts.push({ tag: 'expression', expr })
547
+ continue
548
+ }
549
+
550
+ if (this.scanner.consumeIf('$', false)) {
551
+ // Lone $ not followed by {
552
+ currentString += '$'
553
+ continue
554
+ }
555
+
556
+ // End of template literal
557
+ const end = this.scanner.consume('`', true)
558
+ if (currentString.length > 0) {
559
+ parts.push({ tag: 'string', value: currentString })
560
+ }
561
+ return { tag: 'templateLiteral', parts, start, end, unitId: this.unitId }
562
+ }
563
+ }
564
+
565
+ maybeCompositeLiteral(): AstNode | undefined {
566
+ let t = this.scanner.consumeIf('[')
567
+ if (t) {
568
+ return this.arrayBody(t)
569
+ }
570
+
571
+ t = this.scanner.consumeIf('{')
572
+ if (t) {
573
+ return this.objectBody(t)
574
+ }
575
+
576
+ return undefined
577
+ }
578
+
579
+ /**
580
+ * This method assumes that the caller consumed the opening '[' token. It consumes the array's elements
581
+ * (comma-separated list of expressions) as well as the closing ']' token.
582
+ */
583
+ arrayBody(start: Token): AstNode {
584
+ const t = this.scanner.consumeIf(']')
585
+ if (t) {
586
+ // an empty array literal
587
+ return { tag: 'arrayLiteral', start, parts: [], end: t, unitId: this.unitId }
588
+ }
589
+
590
+ const parts: ArrayLiteralPart[] = []
591
+ while (true) {
592
+ if (this.scanner.consumeIf(',')) {
593
+ const end = this.scanner.consumeIf(']')
594
+ if (end) {
595
+ return { tag: 'arrayLiteral', start, parts, end, unitId: this.unitId }
596
+ }
597
+ continue
598
+ }
599
+ if (this.scanner.consumeIf('...')) {
600
+ parts.push({ tag: 'spread', v: this.expression() })
601
+ } else {
602
+ const exp = this.expression()
603
+ parts.push({ tag: 'element', v: exp })
604
+ }
605
+
606
+ let end = this.scanner.consumeIf(']')
607
+ if (end) {
608
+ return { tag: 'arrayLiteral', start, parts, end, unitId: this.unitId }
609
+ }
610
+
611
+ this.scanner.consume(',')
612
+ end = this.scanner.consumeIf(']')
613
+ if (end) {
614
+ return { tag: 'arrayLiteral', start, parts, end, unitId: this.unitId }
615
+ }
616
+ }
617
+ }
618
+
619
+ /**
620
+ * This method assumes that the caller consumed the opening '{' token. It consumes the object's attributes
621
+ * (comma-separated list of key:value parirs) as well as the closing '}' token.
622
+ */
623
+ objectBody(start: Token): AstNode {
624
+ const t = this.scanner.consumeIf('}')
625
+ if (t) {
626
+ // an empty array literal
627
+ return { tag: 'objectLiteral', start, parts: [], end: t, unitId: this.unitId }
628
+ }
629
+
630
+ const parts: ObjectLiteralPart[] = []
631
+
632
+ const consumePart = () => {
633
+ if (this.scanner.consumeIf('...')) {
634
+ parts.push({ tag: 'spread', o: this.expression() })
635
+ return
636
+ }
637
+
638
+ if (this.scanner.consumeIf('[')) {
639
+ const k = this.expression()
640
+ this.scanner.consume(']')
641
+ this.scanner.consume(':')
642
+ const v = this.expression()
643
+ parts.push({ tag: 'computedName', k, v })
644
+ return
645
+ }
646
+
647
+ const stringLiteral = this.maybeStringLiteral()
648
+ if (stringLiteral) {
649
+ this.scanner.consume(':')
650
+ const v = this.expression()
651
+ parts.push({ tag: 'quotedString', k: stringLiteral, v })
652
+ return
653
+ }
654
+
655
+ const k = this.identifier()
656
+ if (this.scanner.consumeIf(':')) {
657
+ const v = this.expression()
658
+ parts.push({ tag: 'hardName', k, v })
659
+ } else {
660
+ parts.push({ tag: 'hardName', k, v: k })
661
+ }
662
+ }
663
+
664
+ while (true) {
665
+ consumePart()
666
+ let end = this.scanner.consumeIf('}')
667
+ if (end) {
668
+ return { tag: 'objectLiteral', start, parts, end, unitId: this.unitId }
669
+ }
670
+
671
+ this.scanner.consume(',')
672
+ end = this.scanner.consumeIf('}')
673
+ if (end) {
674
+ return { tag: 'objectLiteral', start, parts, end, unitId: this.unitId }
675
+ }
676
+ }
677
+ }
678
+
679
+ private identifier(): Ident {
680
+ const ret = this.maybeIdentifier()
681
+ if (!ret) {
682
+ throw new Error(`Expected an identifier ${this.scanner.sourceRef}`)
683
+ }
684
+
685
+ return ret
686
+ }
687
+
688
+ private maybeIdentifier(): Ident | undefined {
689
+ const t = this.scanner.consumeIf(IDENT_PATTERN)
690
+ if (t) {
691
+ return { tag: 'ident', t, unitId: this.unitId }
692
+ }
693
+
694
+ return undefined
695
+ }
696
+ }
697
+
698
+ const IDENT_PATTERN = /[a-zA-Z][0-9A-Za-z_]*/